hermes-hub/src/antigravity_provider/router/web/server.py
ochenstarik-ui eac8352dc2 fix(release,web): ворота публикации перестали пропускать всё подряд, /api/action — межсайтовые запросы
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>
2026-09-03 19:01:30 +07:00

837 lines
39 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 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()