P0 аудита, каждый сначала подтверждён исполнением, а не принят со слов. 1. Release Gate объявлял проверку хеша, которой не было. Печаталась строка PACKAGE_HASH_VERIFIED=True при том, что hashlib в scripts/release_gate.py не вызывался ни разу: скачивались байты 0-10 через заголовок Range, и этого хватало, чтобы счесть хеш проверенным. «Проверенным ассетом» при этом оказывался первый в списке — checksums.txt, а не пакет. 2. Ворота публикации были fail-open. Измерено в трёх условиях: полный обрыв сети -> PASS, манифест 404 -> PASS, пакет 404 -> PASS. Ворота пропускали релиз при любом исходе, включая полное отсутствие релиза. Разделено на офлайновую часть (проверки 1-7: версии, тесты, updater, статика, секреты, список разрешённых адресов) и Publication Gate: релиз есть, ассеты есть, пакет скачан ЦЕЛИКОМ, SHA-256 сошёлся с опубликованным checksums.txt. Публикационные ворота блокируют в режиме публикации (--publication или HERMES_RELEASE_PUBLICATION_GATE=1); в обычном прогоне CI, где релиза для ветки нет и быть не должно, результат сообщается как есть и не блокирует. Неизмеренное называется причиной, а не выдаётся за проверенное. Проверено на живом релизе v0.1.3-b1: два пакета скачаны целиком, хеши сошлись. 3. POST /api/action на loopback принимал межсайтовые запросы. Токен там не требуется, а действие меняет состояние: удаляет учётные данные, чистит аккаунты, переключает маршрутизацию, запускает входы OAuth. CORS от этого не защищает — он мешает прочитать ответ, а не отправить запрос. Измерено на конфигурации по умолчанию: POST с Content-Type text/plain уходит кросс-сайтом без предварительного запроса, request.json() разбирает тело независимо от Content-Type, и запрос с Origin чужого сайта без токена доходил до исполнителя действий. Проверяется Sec-Fetch-Site, при его отсутствии — Origin против адреса запроса. Собственный интерфейс, адресная строка и не-браузерные клиенты работают как раньше. Защита распространена на все пять небезопасных методов, не только на /api/action. 4. pricing fallback: safe_load вместо safe_dump. dump сериализовал текст обратно в строку, проверка isinstance(data, dict) не выполнялась никогда, таблица цен не загружалась ни разу, а except это глушил. 5. Симуляция Linux в тесте stop_running_hub падала на Windows: os.getuid там не существует. Тесты: 756 -> 776 passed, 2 skipped, 4 deselected. ruff check . чисто. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
837 lines
39 KiB
Python
837 lines
39 KiB
Python
import json
|
||
import secrets
|
||
import os
|
||
import sys
|
||
import threading
|
||
import time
|
||
import dataclasses
|
||
import logging
|
||
from typing import Any, Dict, List, Optional
|
||
from urllib.parse import urlparse
|
||
|
||
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,
|
||
get_installed_build_time,
|
||
get_last_applied_update,
|
||
acknowledge_last_applied_update,
|
||
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
|
||
|
||
# Небезопасные методы принимаются только от собственного интерфейса.
|
||
#
|
||
# На loopback токен не требуется вовсе (см. get_auth_token), а POST /api/action
|
||
# меняет состояние: удаляет учётные данные, чистит аккаунты, переключает
|
||
# маршрутизацию, запускает входы OAuth. CORS от этого не защищает — он мешает
|
||
# ПРОЧИТАТЬ ответ, а не отправить запрос.
|
||
#
|
||
# Проверено на конфигурации по умолчанию (web_api_host=127.0.0.1): POST с
|
||
# Content-Type: text/plain уходит кросс-сайтом без предварительного запроса
|
||
# (простой запрос по правилам CORS), а request.json() разбирает тело
|
||
# независимо от Content-Type. Запрос с Origin: https://evil.example.com и без
|
||
# токена доходил до исполнителя действий — отвечало уже само действие.
|
||
#
|
||
# Проверяется Sec-Fetch-Site, а при его отсутствии — Origin против адреса, по
|
||
# которому пришёл запрос. Отсутствие обоих заголовков означает не-браузерного
|
||
# клиента (curl, собственный CLI): браузер на кросс-сайтовый POST заголовок
|
||
# Origin ставит обязательно, поэтому его отсутствие подделкой не является.
|
||
_SAME_SITE_FETCH_VALUES = {"same-origin", "none"}
|
||
|
||
|
||
def _configured_allowed_origins() -> set:
|
||
raw = str(_web_settings().get("web_api_allowed_origins", "")).split(",")
|
||
return {o.strip() for o in raw if o.strip() and o.strip() != "*"}
|
||
|
||
|
||
def require_same_origin(request: Request) -> bool:
|
||
"""Отклонить межсайтовый небезопасный запрос до того, как он что-то изменит."""
|
||
fetch_site = (request.headers.get("sec-fetch-site") or "").strip().lower()
|
||
if fetch_site:
|
||
if fetch_site in _SAME_SITE_FETCH_VALUES:
|
||
return True
|
||
if request.headers.get("origin", "") in _configured_allowed_origins():
|
||
return True
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail=f"Межсайтовый запрос отклонён (Sec-Fetch-Site: {fetch_site})",
|
||
)
|
||
|
||
origin = request.headers.get("origin")
|
||
if not origin:
|
||
return True
|
||
|
||
host = (request.headers.get("host") or "").strip().lower()
|
||
try:
|
||
origin_host = urlparse(origin).netloc.strip().lower()
|
||
except Exception:
|
||
origin_host = ""
|
||
if origin_host and host and origin_host == host:
|
||
return True
|
||
if origin in _configured_allowed_origins():
|
||
return True
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="Origin не совпадает с адресом хаба: межсайтовый запрос отклонён",
|
||
)
|
||
|
||
|
||
# Коммит и время запуска СНИМАЮТСЯ ОДИН РАЗ, при старте процесса.
|
||
#
|
||
# get_installed_commit() читает манифест с диска при каждом вызове, поэтому
|
||
# переживший обновление процесс бодро рапортует свежий коммит: владелец видит
|
||
# новый номер и старое поведение. Отличить сборки по такому полю нельзя.
|
||
#
|
||
# Снятое при старте значение отвечает на настоящий вопрос: какой код сейчас
|
||
# в памяти. Вместе со временем запуска этого достаточно, чтобы понять, дошло
|
||
# ли обновление до работы, а не только до файлов.
|
||
RUNNING_COMMIT: str = get_installed_commit()
|
||
PROCESS_STARTED_AT: float = time.time()
|
||
|
||
|
||
@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(),
|
||
"running_commit": RUNNING_COMMIT,
|
||
"started_at": PROCESS_STARTED_AT,
|
||
# 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": "terminal-or-redirect",
|
||
"hint": "Вход через agy в терминале (основной) или по ссылке в браузере (запасной)",
|
||
},
|
||
"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
|
||
),
|
||
}
|
||
|
||
snap_dict["version"] = __version__
|
||
snap_dict["commit"] = get_installed_commit()
|
||
snap_dict["running_commit"] = RUNNING_COMMIT
|
||
snap_dict["started_at"] = PROCESS_STARTED_AT
|
||
snap_dict["system_paths"] = {
|
||
"hermes_home": str(paths.get_hermes_home()),
|
||
"config_dir": str(paths.get_config_dir()),
|
||
"log_file": str(paths.get_log_file()),
|
||
}
|
||
snap_dict["last_applied_update"] = get_last_applied_update()
|
||
try:
|
||
from antigravity_provider.router.agy_eligibility_service import AgyEligibilityService
|
||
snap_dict["agy_eligibility"] = AgyEligibilityService.get().check_eligibility_state(force=False)
|
||
except Exception as exc:
|
||
snap_dict["agy_eligibility"] = {
|
||
"status": "unknown",
|
||
"status_label_ru": f"Н/Д: {exc}",
|
||
"detail_ru": str(exc),
|
||
"version": "Н/Д",
|
||
"binary_path": "",
|
||
"binary_sha256": "",
|
||
"binary_size_bytes": 0,
|
||
"checked_at": time.time(),
|
||
"patch_script_path": "",
|
||
}
|
||
return JSONResponse(content=jsonable_encoder(snap_dict))
|
||
|
||
@app.post("/api/action")
|
||
async def handle_action(
|
||
request: Request,
|
||
authorized: bool = Depends(get_auth_token),
|
||
same_origin: bool = Depends(require_same_origin),
|
||
):
|
||
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),
|
||
same_origin: bool = Depends(require_same_origin),
|
||
):
|
||
"""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),
|
||
same_origin: bool = Depends(require_same_origin),
|
||
):
|
||
"""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),
|
||
same_origin: bool = Depends(require_same_origin),
|
||
):
|
||
"""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/compression/status")
|
||
def get_compression_status_endpoint(authorized: bool = Depends(get_auth_token)):
|
||
"""Return real-time diagnostic status of context compressor."""
|
||
from antigravity_provider.router.settings_service import get_hub_settings
|
||
from antigravity_provider.router.local_supervisor import LocalSupervisor
|
||
|
||
hub_settings = get_hub_settings()
|
||
c_pid = hub_settings.get("compressor_profile_id")
|
||
c_pcfg = None
|
||
if c_pid:
|
||
try:
|
||
c_pcfg = load_router_config().get_profile(c_pid)
|
||
except Exception:
|
||
pass
|
||
supervisor = LocalSupervisor()
|
||
status_data = supervisor.get_compression_status(c_pcfg)
|
||
status_data["threshold_percent"] = hub_settings.get("compression_threshold_percent", 75.0)
|
||
status_data["keep_recent_messages"] = hub_settings.get("compression_keep_recent_messages", 3)
|
||
status_data["compression_enabled"] = hub_settings.get("compression_enabled", True)
|
||
return JSONResponse(content=jsonable_encoder(status_data))
|
||
|
||
|
||
@app.get("/api/compression/history")
|
||
def get_compression_history_endpoint(limit: int = 20, authorized: bool = Depends(get_auth_token)):
|
||
"""Return historical record of recent context compressions."""
|
||
from antigravity_provider.router.context_compressor import ContextCompressor
|
||
history = ContextCompressor().get_compression_history(limit=limit)
|
||
return JSONResponse(content=jsonable_encoder({"history": history}))
|
||
|
||
|
||
@app.post("/api/compression/test")
|
||
async def test_compression_endpoint(
|
||
request: Request,
|
||
authorized: bool = Depends(get_auth_token),
|
||
same_origin: bool = Depends(require_same_origin),
|
||
):
|
||
"""Execute test context compression on synthetic benchmark prompt."""
|
||
from antigravity_provider.router.settings_service import get_hub_settings
|
||
from antigravity_provider.router.local_supervisor import LocalSupervisor
|
||
from dataclasses import asdict
|
||
|
||
try:
|
||
data = await request.json()
|
||
except Exception:
|
||
data = {}
|
||
|
||
hub_settings = get_hub_settings()
|
||
c_pid = data.get("profile_id") or hub_settings.get("compressor_profile_id")
|
||
c_pcfg = None
|
||
if c_pid and c_pid != "none":
|
||
try:
|
||
c_pcfg = load_router_config().get_profile(c_pid)
|
||
except Exception:
|
||
pass
|
||
|
||
test_messages = [
|
||
{"role": "system", "content": "You are a software engineer."},
|
||
{"role": "user", "content": "Hermes Hub server runs on 192.168.1.81:8765. The primary coder is on port 8081 with 224K context (229376 tokens), generating at 107.4 tok/s. VRAM usage: 30008 MiB. Active branch: antigravity/a56-context-compression, Commit SHA: 26f7d2c, version v0.1.2. Local compressor is on port 8082."},
|
||
{"role": "assistant", "content": "Acknowledged. All server metrics and ports are noted."},
|
||
{"role": "user", "content": "Now run preflight diagnostics for LocalSupervisor and ContextCompressor in src/antigravity_provider/router/local_supervisor.py."},
|
||
{"role": "assistant", "content": "Diagnostics completed successfully. Memory vault is at /srv/projects/AI-Memory."},
|
||
{"role": "user", "content": "What is our current task?"},
|
||
]
|
||
|
||
supervisor = LocalSupervisor()
|
||
new_msgs, outcome = supervisor.compress_context_if_needed(
|
||
messages=test_messages,
|
||
target_context_limit=32768,
|
||
compressor_profile=c_pcfg,
|
||
threshold_percent=0.0, # force compression
|
||
keep_recent_messages=2,
|
||
)
|
||
return JSONResponse(content=jsonable_encoder({
|
||
"ok": outcome.status == "SUCCESS",
|
||
"message": outcome.status_message,
|
||
"data": {
|
||
"outcome": asdict(outcome) if hasattr(outcome, "__dataclass_fields__") else outcome.__dict__,
|
||
"messages_before_count": len(test_messages),
|
||
"messages_after_count": len(new_msgs),
|
||
}
|
||
}))
|
||
|
||
|
||
@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")
|
||
system_paths = {
|
||
"hermes_home": str(paths.get_hermes_home()),
|
||
"config_dir": str(paths.get_config_dir()),
|
||
"log_file": str(paths.get_log_file()),
|
||
}
|
||
settings_out: Dict[str, Any] = {
|
||
"system_paths": system_paths,
|
||
"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", raw.get("account_check_interval_seconds", 300)),
|
||
"hermes_home": system_paths["hermes_home"],
|
||
"config_dir": system_paths["config_dir"],
|
||
"log_file": system_paths["log_file"],
|
||
"installed_commit": get_installed_commit(),
|
||
# Версия между сборками не меняется намеренно, а коммит — строка из
|
||
# шестнадцатеричных цифр, по которой трудно на глаз отличить старую
|
||
# сборку от новой. Время установки отвечает на этот вопрос сразу.
|
||
"installed_at": get_installed_build_time(),
|
||
"version": __version__,
|
||
"last_update_check": last_check.to_dict() if last_check else None,
|
||
"last_applied_update": get_last_applied_update(),
|
||
"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
|
||
),
|
||
},
|
||
}
|
||
try:
|
||
from antigravity_provider.router.agy_eligibility_service import AgyEligibilityService
|
||
settings_out["agy_eligibility"] = AgyEligibilityService.get().check_eligibility_state(force=False)
|
||
except Exception as exc:
|
||
settings_out["agy_eligibility"] = {
|
||
"status": "unknown",
|
||
"status_label_ru": f"Н/Д: {exc}",
|
||
"detail_ru": str(exc),
|
||
"version": "Н/Д",
|
||
"binary_path": "",
|
||
"binary_sha256": "",
|
||
"binary_size_bytes": 0,
|
||
"checked_at": time.time(),
|
||
"patch_script_path": "",
|
||
}
|
||
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,
|
||
)
|
||
|
||
# A48 и A49 добавили новые файлы клиента, а заголовки кэширования им не
|
||
# прописали: браузер держал их сколько угодно. Владелец обновлял сборку и
|
||
# видел прежний интерфейс — ровно та поломка, ради которой запрет кэша
|
||
# вводился для app.js. Перечисление сделано общим, чтобы следующий
|
||
# добавленный файл не оказался снова без заголовков.
|
||
for _client_script in ("workspace.js", "workflow.js", "workflow.css"):
|
||
|
||
def _make_route(name: str):
|
||
media = "text/css" if name.endswith(".css") else "application/javascript"
|
||
|
||
def _serve():
|
||
return FileResponse(str(_STATIC_DIR / name), media_type=media, headers=_NO_CACHE)
|
||
|
||
return _serve
|
||
|
||
app.get("/" + _client_script)(_make_route(_client_script))
|
||
|
||
@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)
|
||
|
||
# Check if update was just applied and log event to EventLogService
|
||
try:
|
||
from antigravity_provider.router.unified_health import EventLogService
|
||
applied = get_last_applied_update()
|
||
if applied and not applied.get("acknowledged"):
|
||
prev_v = applied.get("prev_version", "unknown")
|
||
prev_c = (applied.get("prev_commit") or "unknown")[:7]
|
||
new_v = applied.get("new_version", "unknown")
|
||
new_c = (applied.get("new_commit") or "unknown")[:7]
|
||
EventLogService.get().log(
|
||
"system",
|
||
f"Hermes Hub успешно обновлён с {prev_v} ({prev_c}) до {new_v} ({new_c})",
|
||
level="info",
|
||
)
|
||
acknowledge_last_applied_update()
|
||
logger.info("Recorded post-update event in EventLogService: %s -> %s", prev_c, new_c)
|
||
except Exception as exc:
|
||
logger.debug("Check last_applied_update on startup 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()
|