Вход через терминал прошёл: agy запустился в изолированном каталоге ag-5 и опознал аккаунт владельца. Отказал Google: «Eligibility check failed: not currently available in your location». Проверка смотрит на адрес выхода. У владельца есть узлы 3x-ui в разных странах. Поэтому обход делается выходом через разрешённую страну, а не патчем чужого бинарника: ничего не ломается при обновлении Antigravity и не выполняется сторонний код. Адрес задаётся общий в настройках и отдельный на профиль — разным аккаунтам может требоваться разная страна. Применяется к запросу каталога моделей, к вызовам моделей и к сценарию входа в терминале. Пишутся и заглавные, и строчные имена переменных: Go читает HTTPS_PROXY, многие библиотеки — https_proxy; ALL_PROXY нужен для socks5. Адрес проверяется по существу, а не по схеме: приписать socks5:// можно чему угодно, и тогда мусор выглядел бы принятым, а обращения провайдера молча ломались бы без внятной причины. 695 passed, 2 skipped; ruff чисто; релизный гейт пройден. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
432 lines
15 KiB
Python
432 lines
15 KiB
Python
"""Hermes Hub — Central Hub Settings Service.
|
||
|
||
Provides unified reading, saving, and querying of runtime settings from hub_settings.json,
|
||
along with Obsidian shared memory validation and canonical structure setup.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from urllib.parse import urlparse
|
||
import re
|
||
import logging
|
||
import os
|
||
import tempfile
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from antigravity_provider.paths import get_hermes_home
|
||
|
||
logger = logging.getLogger("hermes.router.settings")
|
||
|
||
DEFAULT_SETTINGS: Dict[str, Any] = {
|
||
"session_affinity": True,
|
||
"auto_failover": True,
|
||
"failover_attempts": 3,
|
||
"auto_return_primary": True,
|
||
"auto_monitoring": True,
|
||
"auto_update": True,
|
||
"release_channel": "stable",
|
||
"model_timeout_seconds": 60,
|
||
"account_check_interval_seconds": 300,
|
||
"monitoring_interval_seconds": 30,
|
||
"quota_threshold_percent": 10.0,
|
||
"quota_threshold_action": "notify",
|
||
"email_masking_mode": "none",
|
||
"default_role": "manager",
|
||
"obsidian_vault_path": "/srv/projects/AI-Memory",
|
||
"compressor_profile_id": None,
|
||
"compression_threshold_percent": 75.0,
|
||
"compression_keep_recent_messages": 3,
|
||
"compression_enabled": True,
|
||
# Прокси для обращений провайдеров, у которых проверка доступности
|
||
# смотрит на адрес. Google отказал владельцу: «not currently available
|
||
# in your location». Задаётся адресом вида socks5://127.0.0.1:1080 или
|
||
# http://127.0.0.1:8080; пустое значение означает «без прокси».
|
||
"provider_proxy_url": "",
|
||
}
|
||
|
||
|
||
_SETTINGS_CACHE: Dict[str, Any] | None = None
|
||
_SETTINGS_CACHE_MTIME: float = -1.0
|
||
_SETTINGS_CACHE_PATH: str = ""
|
||
|
||
|
||
def invalidate_settings_cache() -> None:
|
||
"""Clear in-memory settings cache."""
|
||
global _SETTINGS_CACHE, _SETTINGS_CACHE_MTIME, _SETTINGS_CACHE_PATH
|
||
_SETTINGS_CACHE = None
|
||
_SETTINGS_CACHE_MTIME = -1.0
|
||
_SETTINGS_CACHE_PATH = ""
|
||
|
||
|
||
def get_settings_file() -> Path:
|
||
"""Return the absolute path to hub_settings.json in HERMES_HOME."""
|
||
return get_hermes_home() / "hub_settings.json"
|
||
|
||
|
||
def _normalize_proxy_url(raw: Any) -> str:
|
||
"""Привести адрес прокси к рабочему виду или отбросить.
|
||
|
||
Проверяем не только схему, но и сам адрес: приписать socks5:// можно
|
||
чему угодно, и тогда мусор выглядел бы принятым, а все обращения
|
||
провайдера молча ломались бы без внятной причины.
|
||
"""
|
||
value = str(raw or "").strip()
|
||
if not value:
|
||
return ""
|
||
if "://" not in value:
|
||
value = "socks5://" + value
|
||
try:
|
||
parsed = urlparse(value)
|
||
except ValueError:
|
||
return ""
|
||
if parsed.scheme not in ("http", "https", "socks5", "socks5h"):
|
||
return ""
|
||
host = parsed.hostname or ""
|
||
if not host or not re.fullmatch(r"[A-Za-z0-9._\-\[\]:]+", host):
|
||
return ""
|
||
try:
|
||
port = parsed.port
|
||
except ValueError:
|
||
return ""
|
||
if port is not None and not (1 <= port <= 65535):
|
||
return ""
|
||
return value
|
||
|
||
|
||
def get_hub_settings() -> Dict[str, Any]:
|
||
"""Load settings from hub_settings.json merged with standard defaults, cached by mtime."""
|
||
global _SETTINGS_CACHE, _SETTINGS_CACHE_MTIME, _SETTINGS_CACHE_PATH
|
||
sfile = get_settings_file()
|
||
sfile_str = str(sfile)
|
||
|
||
current_mtime = -1.0
|
||
if sfile.exists():
|
||
try:
|
||
current_mtime = sfile.stat().st_mtime
|
||
except Exception:
|
||
current_mtime = -1.0
|
||
|
||
if (
|
||
_SETTINGS_CACHE is not None
|
||
and _SETTINGS_CACHE_PATH == sfile_str
|
||
and _SETTINGS_CACHE_MTIME == current_mtime
|
||
):
|
||
return dict(_SETTINGS_CACHE)
|
||
|
||
merged = dict(DEFAULT_SETTINGS)
|
||
if sfile.exists():
|
||
try:
|
||
data = json.loads(sfile.read_text(encoding="utf-8"))
|
||
if isinstance(data, dict):
|
||
merged.update(data)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
merged["account_check_interval_seconds"] = max(60, int(merged.get("account_check_interval_seconds", 300)))
|
||
except (ValueError, TypeError):
|
||
merged["account_check_interval_seconds"] = 300
|
||
|
||
# Normalize numeric types
|
||
try:
|
||
merged["failover_attempts"] = int(merged.get("failover_attempts", 3))
|
||
except (ValueError, TypeError):
|
||
merged["failover_attempts"] = 3
|
||
|
||
try:
|
||
merged["model_timeout_seconds"] = int(merged.get("model_timeout_seconds", 60))
|
||
except (ValueError, TypeError):
|
||
merged["model_timeout_seconds"] = 60
|
||
|
||
try:
|
||
merged["monitoring_interval_seconds"] = int(merged.get("monitoring_interval_seconds", 30))
|
||
except (ValueError, TypeError):
|
||
merged["monitoring_interval_seconds"] = 30
|
||
|
||
try:
|
||
merged["quota_threshold_percent"] = float(merged.get("quota_threshold_percent", 10.0))
|
||
except (ValueError, TypeError):
|
||
merged["quota_threshold_percent"] = 10.0
|
||
|
||
action = str(merged.get("quota_threshold_action", "notify")).strip().lower()
|
||
if action not in ("notify", "switch"):
|
||
action = "notify"
|
||
merged["quota_threshold_action"] = action
|
||
|
||
email_mode = str(merged.get("email_masking_mode", "none")).strip().lower()
|
||
if email_mode not in ("none", "partial", "full"):
|
||
email_mode = "none"
|
||
merged["email_masking_mode"] = email_mode
|
||
|
||
default_role = str(merged.get("default_role", "manager")).strip().lower()
|
||
merged["default_role"] = default_role or "manager"
|
||
|
||
vault_path = str(merged.get("obsidian_vault_path", "/srv/projects/AI-Memory")).strip()
|
||
merged["obsidian_vault_path"] = vault_path
|
||
|
||
compressor_pid = merged.get("compressor_profile_id")
|
||
if compressor_pid is not None and str(compressor_pid).strip() and str(compressor_pid).strip() != "none":
|
||
merged["compressor_profile_id"] = str(compressor_pid).strip()
|
||
else:
|
||
merged["compressor_profile_id"] = None
|
||
|
||
try:
|
||
merged["compression_threshold_percent"] = max(10.0, min(95.0, float(merged.get("compression_threshold_percent", 75.0))))
|
||
except (ValueError, TypeError):
|
||
merged["compression_threshold_percent"] = 75.0
|
||
|
||
# Адрес прокси проверяем на вид, а не принимаем что попало: неверная
|
||
# схема тихо ломает все обращения провайдера, и причина не видна.
|
||
merged["provider_proxy_url"] = _normalize_proxy_url(merged.get("provider_proxy_url"))
|
||
|
||
try:
|
||
merged["compression_keep_recent_messages"] = max(1, min(20, int(merged.get("compression_keep_recent_messages", 3))))
|
||
except (ValueError, TypeError):
|
||
merged["compression_keep_recent_messages"] = 3
|
||
|
||
merged["compression_enabled"] = bool(merged.get("compression_enabled", True))
|
||
|
||
_SETTINGS_CACHE = dict(merged)
|
||
_SETTINGS_CACHE_MTIME = current_mtime
|
||
_SETTINGS_CACHE_PATH = sfile_str
|
||
|
||
return dict(merged)
|
||
|
||
|
||
def save_hub_settings(settings: Dict[str, Any]) -> bool:
|
||
"""Persist settings dictionary into hub_settings.json."""
|
||
try:
|
||
sfile = get_settings_file()
|
||
sfile.parent.mkdir(parents=True, exist_ok=True)
|
||
current = get_hub_settings()
|
||
current.update(settings)
|
||
sfile.write_text(json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8")
|
||
invalidate_settings_cache()
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def validate_obsidian_vault_path(path: Optional[str]) -> Tuple[bool, str, Dict[str, Any]]:
|
||
"""Validate that the given path is an existing, writable Obsidian vault containing .obsidian.
|
||
|
||
Returns (is_valid, message, details_dict).
|
||
"""
|
||
if not path or not str(path).strip():
|
||
return True, "Хранилище не указано. Хаб работает штатно без памяти.", {
|
||
"configured": False,
|
||
"valid": True,
|
||
"path": None,
|
||
"notes_count": 0,
|
||
}
|
||
|
||
clean_path = str(path).strip()
|
||
p = Path(clean_path).expanduser().resolve()
|
||
|
||
if not p.exists():
|
||
return False, f"Каталог '{p}' не существует", {
|
||
"configured": True,
|
||
"valid": False,
|
||
"path": str(p),
|
||
"error": "directory_not_found",
|
||
}
|
||
|
||
if not p.is_dir():
|
||
return False, f"Путь '{p}' не является директорией", {
|
||
"configured": True,
|
||
"valid": False,
|
||
"path": str(p),
|
||
"error": "not_a_directory",
|
||
}
|
||
|
||
# Test write permissions
|
||
try:
|
||
test_file = p / f".hermes_write_test_{os.getpid()}"
|
||
test_file.write_text("test", encoding="utf-8")
|
||
test_file.unlink(missing_ok=True)
|
||
except Exception as exc:
|
||
return False, f"Каталог '{p}' недоступен для записи: {exc}", {
|
||
"configured": True,
|
||
"valid": False,
|
||
"path": str(p),
|
||
"error": "not_writable",
|
||
}
|
||
|
||
# Check for .obsidian marker directory
|
||
obsidian_dir = p / ".obsidian"
|
||
if not obsidian_dir.exists() or not obsidian_dir.is_dir():
|
||
return False, f"Каталог '{p}' не содержит папку '.obsidian' (не является хранилищем Obsidian)", {
|
||
"configured": True,
|
||
"valid": False,
|
||
"path": str(p),
|
||
"error": "missing_obsidian_dir",
|
||
}
|
||
|
||
# Count notes
|
||
try:
|
||
notes_count = len(list(p.glob("**/*.md")))
|
||
except Exception:
|
||
notes_count = 0
|
||
|
||
return True, f"Хранилище Obsidian доступно ({notes_count} заметок)", {
|
||
"configured": True,
|
||
"valid": True,
|
||
"path": str(p),
|
||
"notes_count": notes_count,
|
||
}
|
||
|
||
|
||
def setup_memory_structure(
|
||
vault_path: Optional[str] = None,
|
||
project_name: str = "hermes-hub",
|
||
) -> Dict[str, Any]:
|
||
"""Check and deploy canonical Obsidian memory structure without modifying or deleting existing notes.
|
||
|
||
Canonical structure:
|
||
- 00_SYSTEM/
|
||
- 01_PROJECTS/<project_name>/
|
||
- 01_PROJECTS/<project_name>/worklog/
|
||
- 03_LESSONS/
|
||
- 04_PATTERNS/
|
||
- 05_AGENTS/
|
||
- worklog/
|
||
"""
|
||
target_path = vault_path or get_hub_settings().get("obsidian_vault_path") or "/srv/projects/AI-Memory"
|
||
is_valid, msg, details = validate_obsidian_vault_path(target_path)
|
||
if not is_valid:
|
||
return {
|
||
"ok": False,
|
||
"message": f"Не удалось развернуть память: {msg}",
|
||
"details": details,
|
||
}
|
||
|
||
p = Path(target_path).expanduser().resolve()
|
||
canonical_dirs = [
|
||
"00_SYSTEM",
|
||
f"01_PROJECTS/{project_name}",
|
||
f"01_PROJECTS/{project_name}/worklog",
|
||
"03_LESSONS",
|
||
"04_PATTERNS",
|
||
"05_AGENTS",
|
||
"worklog",
|
||
]
|
||
|
||
created_dirs: List[str] = []
|
||
existing_dirs: List[str] = []
|
||
|
||
for d_rel in canonical_dirs:
|
||
d_abs = p / d_rel
|
||
if d_abs.exists():
|
||
existing_dirs.append(d_rel)
|
||
else:
|
||
try:
|
||
d_abs.mkdir(parents=True, exist_ok=True)
|
||
created_dirs.append(d_rel)
|
||
except Exception as exc:
|
||
logger.error("Failed to create memory directory %s: %s", d_abs, exc)
|
||
|
||
# Count total notes
|
||
notes_count = len(list(p.glob("**/*.md")))
|
||
|
||
return {
|
||
"ok": True,
|
||
"message": f"Структура памяти Obsidian проверена и развёрнута ({len(created_dirs)} создано, {len(existing_dirs)} существовало, {notes_count} заметок).",
|
||
"vault_path": str(p),
|
||
"notes_count": notes_count,
|
||
"created_dirs": created_dirs,
|
||
"existing_dirs": existing_dirs,
|
||
}
|
||
def get_hermes_config_status(config_path: Optional[Path] = None) -> Dict[str, Any]:
|
||
"""Read Hermes configuration (~/.hermes/config.yaml) in read-only mode to provide status feedback."""
|
||
import yaml
|
||
checked: list = []
|
||
if config_path is None:
|
||
# Hermes хранит конфигурацию не в одном месте: путь зависит от версии и
|
||
# способа установки. Раньше проверялся ровно один файл, и у владельца на
|
||
# Linux хаб писал «конфигурация не найдена» при работающем Hermes.
|
||
home = get_hermes_home()
|
||
candidates = [
|
||
home / "config.yaml",
|
||
home / "config.yml",
|
||
home / "config.json",
|
||
home / "hermes-agent" / "config.yaml",
|
||
Path.home() / ".hermes" / "config.yaml",
|
||
Path.home() / ".config" / "hermes" / "config.yaml",
|
||
Path.home() / ".config" / "hermes-agent" / "config.yaml",
|
||
]
|
||
seen = set()
|
||
for candidate in candidates:
|
||
key = str(candidate)
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
checked.append(key)
|
||
try:
|
||
if candidate.exists():
|
||
config_path = candidate
|
||
break
|
||
except OSError as exc:
|
||
# Каталог может быть закрыт правами: это «не смогли проверить»,
|
||
# а не «файла нет». Разница важна, иначе диагноз ложный.
|
||
checked[-1] = f"{key} (нет доступа: {exc.strerror or exc})"
|
||
else:
|
||
return {
|
||
"exists": False,
|
||
"model": None,
|
||
"provider": None,
|
||
"base_url": None,
|
||
"path": None,
|
||
"checked_paths": checked,
|
||
"message": "Конфигурация Hermes не найдена. Проверено: " + "; ".join(checked),
|
||
}
|
||
|
||
if not config_path.exists():
|
||
return {
|
||
"exists": False,
|
||
"model": None,
|
||
"provider": None,
|
||
"base_url": None,
|
||
"path": str(config_path),
|
||
"checked_paths": checked or [str(config_path)],
|
||
"message": f"Конфигурация Hermes не найдена по пути {config_path}",
|
||
}
|
||
|
||
try:
|
||
raw_text = config_path.read_text(encoding="utf-8")
|
||
data = yaml.safe_load(raw_text)
|
||
if not isinstance(data, dict):
|
||
return {
|
||
"exists": True,
|
||
"model": None,
|
||
"provider": None,
|
||
"base_url": None,
|
||
"path": str(config_path),
|
||
"message": "Конфигурационный файл пуст или некорректен",
|
||
}
|
||
|
||
model_cfg = data.get("model", {})
|
||
if not isinstance(model_cfg, dict):
|
||
model_cfg = {}
|
||
|
||
default_model = model_cfg.get("default") or data.get("default_model") or data.get("model")
|
||
provider = model_cfg.get("provider") or data.get("provider")
|
||
base_url = model_cfg.get("base_url") or data.get("base_url")
|
||
|
||
return {
|
||
"exists": True,
|
||
"model": default_model,
|
||
"provider": provider,
|
||
"base_url": base_url,
|
||
"path": str(config_path),
|
||
}
|
||
except Exception as e:
|
||
return {
|
||
"exists": True,
|
||
"model": None,
|
||
"provider": None,
|
||
"base_url": None,
|
||
"path": str(config_path),
|
||
"error": str(e),
|
||
}
|
||
|