hermes-hub/src/antigravity_provider/router/web/server.py

572 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
import secrets
import os
import sys
import threading
import time
import dataclasses
import logging
from typing import Any, Dict, List, Optional
from antigravity_provider import paths
from antigravity_provider.version import __version__
from fastapi import FastAPI, Request, HTTPException, Depends, Header, Response
from pathlib import Path
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from fastapi.encoders import jsonable_encoder
from starlette.concurrency import run_in_threadpool
from antigravity_provider.router.state_store import HubStateStore
from antigravity_provider.router.action_handler import ActionExecutor
from antigravity_provider.router.router_config import load_router_config
from antigravity_provider.updater.update_manager import get_installed_commit, UpdateManager
logger = logging.getLogger("hermes.router.web")
def _bootstrap_settings() -> Dict[str, Any]:
"""Настройки, нужные до объявления _web_settings (список источников CORS)."""
try:
from antigravity_provider.router.settings_service import get_hub_settings
return get_hub_settings() or {}
except Exception:
return {}
app = FastAPI(title="Hermes Hub Web API", version="1.0.0")
# Межсайтовые запросы запрещены по умолчанию.
#
# Стояло allow_origins=["*"] вместе с allow_credentials=True. FastAPI в таком
# сочетании отражает любой присланный Origin обратно, поэтому проверено
# запросом: страница с https://evil.example.com получала
# access-control-allow-origin: https://evil.example.com
# access-control-allow-credentials: true
# на 200 от /api/snapshot.
#
# Опаснее всего это на localhost: там токен не требуется вовсе (см.
# get_auth_token), а значит ЛЮБОЙ сайт, открытый во вкладке рядом, мог читать
# снапшот со всеми аккаунтами и почтами и вызывать /api/action — удалять
# учётные данные, менять маршрутизацию, запускать входы OAuth.
#
# Собственному интерфейсу CORS не нужен: он отдаётся тем же сервером. Список
# разрешённых источников оставлен настройкой — он понадобится, когда одна
# панель будет смотреть на несколько хабов.
_raw_cors = str(_bootstrap_settings().get("web_api_allowed_origins", "")).split(",")
_cors_origins = [
o.strip()
for o in _raw_cors
if o.strip() and o.strip() != "*"
]
if _cors_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=_cors_origins,
allow_credentials=True,
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["X-Hub-Token", "Content-Type", "X-Hub-Actor"],
)
def _web_settings() -> Dict[str, Any]:
"""Настройки веб-API живут в hub_settings.json, а не в RouterConfig."""
from antigravity_provider.router.settings_service import get_hub_settings
return get_hub_settings()
def get_auth_token(x_hub_token: str = Header(None)) -> bool:
settings = _web_settings()
server_host = settings.get('web_api_host', '127.0.0.1')
if server_host != '127.0.0.1':
required_token = settings.get('web_api_token', '')
if not required_token:
raise HTTPException(status_code=500, detail="Server misconfigured: external bind requires a token")
# Сравнение постоянного времени — требование раздела 3 контракта,
# которое до сих пор не было выполнено: стояло обычное !=, дающее
# утечку по времени посимвольного сравнения. Заголовок может быть
# None, поэтому приводим к строке до сравнения.
# Сравниваем в байтах: compare_digest со строками запрещает не-ASCII
# и падает TypeError, то есть токен с кириллицей давал бы 500 вместо
# честного отказа.
_given = str(x_hub_token or '').encode('utf-8')
_needed = str(required_token).encode('utf-8')
if not secrets.compare_digest(_given, _needed):
raise HTTPException(status_code=401, detail="Invalid X-Hub-Token")
return True
@app.get("/api/health")
def health_check():
from ..account_probe_service import AccountProbeService
return {
"pid": os.getpid(),
"account_probe": AccountProbeService.get().status(),
"ok": True,
"version": __version__,
"commit": get_installed_commit(),
# Antigravity и Claude раньше стояли здесь как supported: False с
# советом идти в десктоп или пробрасывать порты. Это было неверно:
# ProfileOAuthSession.handle_manual_callback_url и
# ClaudeOAuthSession.handle_auth_code принимают вставленное вручную
# значение, поэтому браузер нужен где угодно, а не на машине с Hub.
"auth_flows": {
"openai-codex": {"supported": True, "reason": "device-code"},
"grok": {"supported": True, "reason": "device-code"},
"opencode-go": {"supported": True, "reason": "token"},
"antigravity": {
"supported": True,
"reason": "redirect-url-paste",
"hint": "Откройте ссылку в любом браузере и верните адрес из адресной строки",
},
"claude": {
"supported": True,
"reason": "code-paste",
"hint": "Откройте ссылку в любом браузере и верните показанный код",
},
}
}
def sanitize_snapshot(snap_dict: Any, email_masking_mode: Optional[str] = None) -> Any:
import re
if email_masking_mode is None:
try:
from antigravity_provider.router.settings_service import get_hub_settings
email_masking_mode = get_hub_settings().get("email_masking_mode", "none")
except Exception:
email_masking_mode = "none"
mode = str(email_masking_mode or "none").strip().lower()
secret_patterns = [
re.compile(r'((?:access_token|refresh_token|api_key|token|password|secret|key)=)([^\s&,"]+)', re.IGNORECASE),
re.compile(r'(sk-[a-zA-Z0-9_\-]{8,})'),
re.compile(r'(gho_[a-zA-Z0-9_\-]{8,})'),
re.compile(r'(Bearer\s+)([a-zA-Z0-9_\-\.]{8,})', re.IGNORECASE),
]
email_pattern = re.compile(r'\b([A-Za-z0-9._%+-]+)@([A-Za-z0-9.-]+\.[A-Za-z]{2,})\b')
def _mask_email_match(match: re.Match) -> str:
local_part = match.group(1)
domain_part = match.group(2)
if mode == "full":
return "***@***.***"
elif mode == "partial":
if len(local_part) > 2:
masked = f"{local_part[0]}***{local_part[-1]}"
elif local_part:
masked = f"{local_part[0]}***"
else:
masked = "***"
return f"{masked}@{domain_part}"
return match.group(0)
def _mask_str(val: str) -> str:
res = val
for pat in secret_patterns:
if pat.groups == 2:
res = pat.sub(r'\g<1>***', res)
elif pat.groups == 1:
res = pat.sub(r'***', res)
if mode in ("partial", "full"):
res = email_pattern.sub(_mask_email_match, res)
return res
def _sanitize(node):
if isinstance(node, dict):
return {
k: _sanitize(v) for k, v in node.items()
if not any(secret in k.lower() for secret in ['access_token', 'refresh_token', 'api_key', 'jwt', 'client_secret'])
}
elif isinstance(node, list):
return [_sanitize(x) for x in node]
elif isinstance(node, str):
return _mask_str(node)
return node
return _sanitize(snap_dict)
@app.get("/api/snapshot")
def get_snapshot(authorized: bool = Depends(get_auth_token)):
snapshot = HubStateStore.get().get_snapshot()
if not snapshot:
raise HTTPException(status_code=503, detail="Snapshot not ready")
snap_dict = dataclasses.asdict(snapshot)
from ..account_probe_service import AccountProbeService
from ..model_discovery_service import ModelDiscoveryService
probe, discovery = AccountProbeService.get(), ModelDiscoveryService.get()
snap_dict["account_probe"] = probe.status()
for profile in snap_dict.get("all_profiles", {}).values():
pid, provider = profile["profile_id"], profile["provider"]
check = probe.state(pid)
profile["connection_check"] = check
if profile.get("auth_state") == "AUTHENTICATED" and profile.get("enabled", True) and not check.get("models_only"):
if check.get("state") == "working":
profile["health_state"], profile["health_label_ru"] = "healthy", "Проверен: работает"
elif check.get("state") == "failed":
profile["health_state"], profile["health_label_ru"] = "unhealthy", "Проверен: не работает — " + check["message"]
elif check.get("state") == "checking":
profile["health_state"], profile["health_label_ru"] = "checking", "Проверяется…"
profile["model_discovery"] = discovery.get_models_with_metadata(provider, pid)
if provider == "ollama":
profile["model_discovery"]["cloud"] = discovery.get_models_with_metadata("ollama-cloud-catalog")
snap_dict["profiles_by_provider"] = {
provider: [snap_dict["all_profiles"].get(p["profile_id"], p) for p in profiles]
for provider, profiles in snap_dict.get("profiles_by_provider", {}).items()
}
snap_dict = sanitize_snapshot(snap_dict)
server_host = _web_settings().get("web_api_host", "127.0.0.1")
is_external = (server_host != "127.0.0.1" and server_host != "localhost")
snap_dict["network_security"] = {
"is_external_bind": is_external,
"is_tls": False,
"host": server_host,
"warning": (
f"Внимание: Web API привязан к внешнему сетевому интерфейсу ({server_host}) поверх открытого HTTP. Токен авторизации и почты аккаунтов передаются по сети в открытом виде. Рекомендуется использовать HTTPS, VPN или SSH-туннель."
if is_external
else None
),
}
return JSONResponse(content=jsonable_encoder(snap_dict))
@app.post("/api/action")
async def handle_action(request: Request, authorized: bool = Depends(get_auth_token)):
try:
data = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON")
action = data.get("action")
if not action:
raise HTTPException(status_code=400, detail="Missing 'action'")
def _async_runner(func, name):
threading.Thread(target=func, name=name, daemon=True).start()
actor = request.headers.get("X-Hub-Actor") or (f"web:{request.client.host}" if request.client else "user:web")
try:
result = await run_in_threadpool(ActionExecutor.execute, action, data.get("data", {}), async_runner=_async_runner, actor=actor)
except Exception as exc:
result = {"ok": False, "message": f"Действие {action} завершилось ошибкой {type(exc).__name__}"}
if result.get("unknown"):
raise HTTPException(status_code=404, detail="Неизвестное действие")
return {
"ok": result.get("ok", False),
"message": result.get("message") or ("Действие выполнено" if result.get("ok") else f"Действие {action} не выполнено: обработчик не сообщил причину"),
"data": result.get("data", {})
}
@app.get("/api/events")
def get_events(limit: int = 100, category: Optional[str] = None, authorized: bool = Depends(get_auth_token)):
"""Return recent events log in reverse chronological order without secrets."""
from antigravity_provider.router.unified_health import EventLogService
events = EventLogService.get().get_events(limit=limit, category=category)
event_dicts = [dataclasses.asdict(e) for e in events]
sanitized = sanitize_snapshot(event_dicts)
return JSONResponse(content=jsonable_encoder({"events": sanitized}))
@app.get("/api/quotas/export")
def export_quotas_endpoint(
format: str = "json",
authorized: bool = Depends(get_auth_token),
):
"""Export full quotas and limits report across all providers and profiles."""
from antigravity_provider.router.action_handler import generate_quotas_export
fmt = format.lower().strip()
if fmt == "csv":
csv_data = generate_quotas_export(format="csv")
return Response(
content=csv_data,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=hermes_quotas_export.csv"},
)
else:
json_data = generate_quotas_export(format="json")
sanitized = sanitize_snapshot(json_data)
return JSONResponse(
content=jsonable_encoder(sanitized),
headers={"Content-Disposition": "attachment; filename=hermes_quotas_export.json"},
)
@app.get("/api/skills")
def get_skills_endpoint(authorized: bool = Depends(get_auth_token)):
"""Return all discovered skills with metadata, assigned agents, and validation status."""
from antigravity_provider.router.skills_service import SkillsService
skills = SkillsService.get().discover_skills()
skills_dicts = [s.to_dict() for s in skills]
return JSONResponse(content=jsonable_encoder({"skills": skills_dicts}))
@app.post("/api/skills/assign")
async def assign_skill_endpoint(request: Request, authorized: bool = Depends(get_auth_token)):
"""Assign a skill to a specific subagent."""
from antigravity_provider.router.skills_service import SkillsService
try:
data = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON payload")
skill_name = str(data.get("skill_name") or data.get("name") or "").strip()
agent_id = str(data.get("agent_id") or data.get("id") or "").strip()
if not skill_name or not agent_id:
raise HTTPException(status_code=400, detail="Укажите 'skill_name' и 'agent_id'")
try:
res = SkillsService.get().assign_skill(skill_name, agent_id)
return JSONResponse(content=jsonable_encoder(res))
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
@app.post("/api/skills/unassign")
async def unassign_skill_endpoint(request: Request, authorized: bool = Depends(get_auth_token)):
"""Remove an assigned skill from a subagent."""
from antigravity_provider.router.skills_service import SkillsService
try:
data = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON payload")
skill_name = str(data.get("skill_name") or data.get("name") or "").strip()
agent_id = str(data.get("agent_id") or data.get("id") or "").strip()
if not skill_name or not agent_id:
raise HTTPException(status_code=400, detail="Укажите 'skill_name' и 'agent_id'")
try:
res = SkillsService.get().unassign_skill(skill_name, agent_id)
return JSONResponse(content=jsonable_encoder(res))
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
@app.get("/api/skills/usage")
def get_skills_usage_endpoint(authorized: bool = Depends(get_auth_token)):
"""Return truthful skill invocation statistics."""
from antigravity_provider.router.skills_service import SkillsService
usage = SkillsService.get().get_skills_usage()
return JSONResponse(content=jsonable_encoder(usage))
@app.post("/api/skills/diagnose")
async def diagnose_skill_endpoint(request: Request, authorized: bool = Depends(get_auth_token)):
"""Run SkillDoctor diagnostics on a skill by name, filepath, or raw content."""
from antigravity_provider.router.skills_service import SkillsService
try:
data = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON payload")
skill_name = data.get("skill_name") or data.get("name")
filepath = data.get("path") or data.get("filepath")
content = data.get("content")
try:
diag = SkillsService.get().diagnose_skill(skill_name=skill_name, filepath=filepath, content=content)
return JSONResponse(content=jsonable_encoder({"ok": True, "diagnosis": diag.to_dict()}))
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
@app.get("/api/settings")
def get_settings(authorized: bool = Depends(get_auth_token)):
"""Return current server and hub settings without exposing raw auth tokens."""
raw = _web_settings()
has_token = bool(raw.get("web_api_token"))
last_check = UpdateManager.get_last_check_result()
server_host = raw.get("web_api_host", "127.0.0.1")
is_external = (server_host != "127.0.0.1" and server_host != "localhost")
settings_out: Dict[str, Any] = {
"web_api_host": server_host,
"web_api_port": raw.get("web_api_port", 5800),
"web_api_token_configured": has_token,
"theme": raw.get("theme", "system"),
"quota_refresh_interval_sec": raw.get("quota_refresh_interval_sec", 300),
"hermes_home": str(paths.get_hermes_home()),
"config_dir": str(paths.get_config_dir()),
"log_file": str(paths.get_log_file()),
"installed_commit": get_installed_commit(),
"version": __version__,
"last_update_check": last_check.to_dict() if last_check else None,
"network_security": {
"is_external_bind": is_external,
"is_tls": False,
"host": server_host,
"warning": (
f"Внимание: Web API привязан к внешнему сетевому интерфейсу ({server_host}) поверх открытого HTTP. Токен авторизации и почты аккаунтов передаются по сети в открытом виде. Рекомендуется использовать HTTPS, VPN или SSH-туннель."
if is_external
else None
),
},
}
for k, v in raw.items():
if k not in settings_out and not any(secret in k.lower() for secret in ['token', 'secret', 'key', 'password', 'jwt']):
settings_out[k] = v
return JSONResponse(content=jsonable_encoder(settings_out))
def run_web_server(host: Optional[str] = None, port: Optional[int] = None, open_browser: bool = False) -> None:
import uvicorn
import webbrowser
settings = _web_settings()
host = host or settings.get('web_api_host', '127.0.0.1')
port = port or int(settings.get('web_api_port', 5800))
token = settings.get('web_api_token', '')
if host != '127.0.0.1' and not token:
logger.error("Cannot bind Web API externally without web_api_token")
sys.exit(1)
url = f"http://{host}:{port}"
logger.info(f"Starting Hermes Hub Web on {url}")
print(f"Hermes Hub running at {url}")
if open_browser:
def _open():
time.sleep(0.5)
try:
webbrowser.open(url)
except Exception:
pass
threading.Thread(target=_open, daemon=True).start()
uvicorn.run(app, host=host, port=port, log_level="warning")
def start_web_server_background(host: Optional[str] = None, port: Optional[int] = None) -> threading.Thread:
"""Start web server in a daemon thread."""
t = threading.Thread(target=run_web_server, kwargs={"host": host, "port": port, "open_browser": False}, daemon=True)
t.start()
return t
def run_server() -> None:
run_web_server(open_browser=False)
# ─────────────────────────────────────────────────────────────
# Статика: сервер обязан отдавать сам интерфейс, а не только API.
# Контракт описывал каталог static/, но не назвал, кто его монтирует,
# поэтому серверная и клиентская стороны сошлись в пустоту: API
# отвечал, файлы лежали в репозитории, а в браузере был 404.
# ─────────────────────────────────────────────────────────────
_STATIC_DIR = Path(__file__).resolve().parent / "static"
if _STATIC_DIR.is_dir():
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
# Ассеты обязаны перепроверяться при каждой загрузке.
# Без Cache-Control браузер применяет эвристическое кэширование и может
# неделями отдавать старый app.js: интерфейс выглядит обновлённым (index.html
# перепроверяется при навигации), а поведение остаётся от прошлой сборки.
_NO_CACHE = {"Cache-Control": "no-cache, must-revalidate"}
@app.get("/")
@app.get("/index.html")
def index():
return FileResponse(str(_STATIC_DIR / "index.html"), headers=_NO_CACHE)
@app.get("/app.js")
def _app_js():
return FileResponse(
str(_STATIC_DIR / "app.js"),
media_type="application/javascript",
headers=_NO_CACHE,
)
@app.get("/style.css")
def _style_css():
return FileResponse(
str(_STATIC_DIR / "style.css"),
media_type="text/css",
headers=_NO_CACHE,
)
@app.get("/snapshot.example.json")
def _fixture():
return FileResponse(str(_STATIC_DIR / "snapshot.example.json"), media_type="application/json")
# ─────────────────────────────────────────────────────────────
# Фоновое обновление: прогрев квот и пересбор снапшота.
#
# Две причины, по которым /api/snapshot отдавал пустые квоты навсегда:
#
# 1. state_store наполняет квоты через quota_service.get_snapshot, который
# читает кэш и при промахе отдаёт пустую заглушку, живой опрос НЕ
# запуская. В десктопе кэш грел _refresh_quotas_on_startup; в вебе
# такого не было. Штатный планировщик службы сам по себе не спасает:
# его цикл сначала спит интервал (по умолчанию 300 с) и только потом
# опрашивает.
#
# 2. HubStateStore.get_snapshot() возвращает КЭШИРОВАННЫЙ снапшот и
# пересобирает его лишь при самом первом вызове. Даже после прогрева
# квот ответ оставался прежним. В десктопе пересбор делал _refresh_data.
# ─────────────────────────────────────────────────────────────
_SNAPSHOT_REFRESH_SEC = 30
_background_stop = threading.Event()
def _background_refresh_loop() -> None:
from antigravity_provider.router.quota_collector import AccountQuotaService
from antigravity_provider.router.account_probe_service import AccountProbeService
AccountProbeService.get().enabled = True
AccountProbeService.get().tick()
try:
AccountQuotaService.get().fetch_all_configured(force=True)
logger.info("Quota cache warmed on startup")
except Exception as exc:
logger.warning("Quota warm-up failed: %s", exc)
# Initial quiet update check in background
try:
UpdateManager().check_for_updates()
logger.info("Initial update check completed in background")
except Exception as exc:
logger.debug("Initial background update check skipped: %s", exc)
while not _background_stop.is_set():
try:
AccountProbeService.get().tick()
HubStateStore.get().refresh(force_scan=False)
except Exception as exc:
logger.warning("Snapshot refresh failed: %s", exc)
_background_stop.wait(_SNAPSHOT_REFRESH_SEC)
@app.on_event("startup")
def _start_background_refresh() -> None:
_background_stop.clear()
# В фоне: опрос ходит по сети к нескольким провайдерам, держать на нём
# старт сервера нельзя.
threading.Thread(target=_background_refresh_loop, daemon=True, name="hub-web-refresh").start()
from antigravity_provider.router.quota_collector import AccountQuotaService
try:
AccountQuotaService.get().start_background_scheduler()
except Exception as exc:
logger.warning("Could not start quota scheduler: %s", exc)
@app.on_event("shutdown")
def _stop_background_refresh() -> None:
from ..account_probe_service import AccountProbeService
from ..quota_collector import AccountQuotaService
_background_stop.set()
AccountProbeService.get().shutdown()
AccountQuotaService.get().stop_background_scheduler()