From 4b6533281e73a612a35e31ae40e99885dacfb8e2 Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Tue, 1 Sep 2026 01:26:08 +0700 Subject: [PATCH 01/14] =?UTF-8?q?fix(web):=20=D0=BD=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=BA=D0=BB=D0=B8?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=20=D0=BE=D1=82=D0=B4=D0=B0=D0=B2=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D1=81=D1=8C=20=D0=B1=D0=B5=D0=B7=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D1=80=D0=B5=D1=82=D0=B0=20=D0=BA=D1=8D=D1=88=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A48 и A49 добавили workspace.js, workflow.js и workflow.css, но заголовки Cache-Control им не прописали. Проверено запросом к серверу владельца: у /, /app.js и /style.css стоит no-cache, must-revalidate, а у /workspace.js и /workflow.js заголовков кэша нет вовсе. Из-за этого браузер держал старые файлы после обновления сборки, и владелец видел прежний интерфейс при новом номере сборки — та самая поломка, ради которой запрет кэша когда-то вводился для app.js. Перечисление файлов сделано общим списком, чтобы следующий добавленный файл не оказался снова без заголовков. 609 passed, ruff clean. Co-Authored-By: Claude Opus 5 --- src/antigravity_provider/router/web/server.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/antigravity_provider/router/web/server.py b/src/antigravity_provider/router/web/server.py index 6cb6f94..3e2d5ca 100644 --- a/src/antigravity_provider/router/web/server.py +++ b/src/antigravity_provider/router/web/server.py @@ -507,6 +507,23 @@ if _STATIC_DIR.is_dir(): 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") From 266fc51adf83dff755ef760979edfcf7143312ac Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Tue, 1 Sep 2026 01:46:46 +0700 Subject: [PATCH 02/14] =?UTF-8?q?fix(providers):=20=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=20=D0=BD=D0=B5=20=D0=B4=D0=BE=D1=85=D0=BE=D0=B4=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=B4=D0=BE=20=D0=B0=D0=B4=D0=B0=D0=BF=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=B0;=20=D1=81=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA=20?= =?UTF-8?q?=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82=D0=BE=D0=B2=20=D1=80?= =?UTF-8?q?=D0=B0=D1=81=D1=85=D0=BE=D0=B4=D0=B8=D0=BB=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ключ API. add_account сохраняет его через ProfileAuthManager, а адаптеры NVIDIA и OpenRouter читали только profile.auth_config из router_profiles.yaml — туда ключ не попадает. Запрос уходил без заголовка Authorization, и провайдер отвечал «401: Header of type authorization was missing», хотя список моделей тем же ключом получался: обнаружение читает ключ из хранилища, а адаптер читал из конфигурации. Проверено: auth_config в yaml пуст, адаптер теперь находит ключ в хранилище учётных данных. Счётчик аккаунтов. Значок в меню брал readiness.accounts_connected_count (строго AUTHENTICATED), а карточка на странице считала профили правилом «не NOT_CONFIGURED». Владелец видел 9 в меню и 3 на странице. Приведено к одному определению — тому же, что у страницы. 609 passed, ruff clean, релизный гейт 10/10. Co-Authored-By: Claude Opus 5 --- .../router/adapters/nvidia_adapter.py | 14 ++++++++++++++ .../router/adapters/openrouter_adapter.py | 14 ++++++++++++++ src/antigravity_provider/router/web/static/app.js | 8 +++++--- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/antigravity_provider/router/adapters/nvidia_adapter.py b/src/antigravity_provider/router/adapters/nvidia_adapter.py index af8520e..12c29ad 100644 --- a/src/antigravity_provider/router/adapters/nvidia_adapter.py +++ b/src/antigravity_provider/router/adapters/nvidia_adapter.py @@ -38,6 +38,20 @@ class NvidiaAdapter(BaseProviderAdapter): if key: return str(key).strip() + # Ключ, введённый в мастере, сохраняется через ProfileAuthManager, а не в + # auth_config из router_profiles.yaml. Адаптер читал только второе место и + # уходил без заголовка Authorization: провайдер отвечал 401 «Header of type + # authorization was missing», хотя список моделей тем же ключом получался. + try: + from antigravity_provider.router.profile_manager import ProfileAuthManager + + stored = ProfileAuthManager.load_profile_auth(profile.provider, profile.profile_id) or {} + key = stored.get("api_key") or stored.get("token") + if key: + return str(key).strip() + except Exception: + pass + suffix = profile.profile_id.upper().replace("-", "_") for candidate in (f"NVIDIA_API_KEY_{suffix}", "NVIDIA_API_KEY", "NV_API_KEY"): val = os.environ.get(candidate, "").strip() diff --git a/src/antigravity_provider/router/adapters/openrouter_adapter.py b/src/antigravity_provider/router/adapters/openrouter_adapter.py index f4b0777..0531ed0 100644 --- a/src/antigravity_provider/router/adapters/openrouter_adapter.py +++ b/src/antigravity_provider/router/adapters/openrouter_adapter.py @@ -41,6 +41,20 @@ class OpenRouterAdapter(BaseProviderAdapter): if key: return str(key).strip() + # Ключ, введённый в мастере, сохраняется через ProfileAuthManager, а не в + # auth_config из router_profiles.yaml. Адаптер читал только второе место и + # уходил без заголовка Authorization: провайдер отвечал 401 «Header of type + # authorization was missing», хотя список моделей тем же ключом получался. + try: + from antigravity_provider.router.profile_manager import ProfileAuthManager + + stored = ProfileAuthManager.load_profile_auth(profile.provider, profile.profile_id) or {} + key = stored.get("api_key") or stored.get("token") + if key: + return str(key).strip() + except Exception: + pass + suffix = profile.profile_id.upper().replace("-", "_") for candidate in (f"OPENROUTER_API_KEY_{suffix}", "OPENROUTER_API_KEY"): val = os.environ.get(candidate, "").strip() diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index 440fb9e..91880d0 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -576,9 +576,11 @@ function updateGlobalHeader() { const readiness = currentSnapshot.readiness || {}; renderAccountSummary(currentSnapshot); const allProfiles = Object.values(currentSnapshot.all_profiles || {}); - const connectedAccounts = readiness.accounts_connected_count ?? allProfiles.filter( - (p) => isConnectedProfile(p) - ).length; + // Одно число — одно определение. Готовность считает строго AUTHENTICATED, + // а страница аккаунтов — всё, что не NOT_CONFIGURED. Из-за двух определений + // значок в меню показывал 9, а карточка на той же странице — 3. + // Берём то же правило, что и страница аккаунтов: расходиться они не должны. + const connectedAccounts = allProfiles.filter((p) => isConnectedProfile(p)).length; if (elements.navAccountsCount) elements.navAccountsCount.textContent = connectedAccounts; From f269891271028cb9192588ef026543c6b8392dbe Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Tue, 1 Sep 2026 02:07:11 +0700 Subject: [PATCH 03/14] =?UTF-8?q?fix:=20agy=20=D0=BD=D0=B5=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=D0=B8=D0=BB=D1=81=D1=8F=20=D0=B2=20Linux;=20?= =?UTF-8?q?Ollama=20Cloud=20=D0=BD=D0=B5=D0=BB=D1=8C=D0=B7=D1=8F=20=D0=B1?= =?UTF-8?q?=D1=8B=D0=BB=D0=BE=20=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BF=D0=BE=20=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agy. Поиск проверял только раскладку Windows (%LOCALAPPDATA%/agy/bin) и PATH. В Linux утилита ставится в ~/.local/bin, а хаб запускается с урезанным окружением, где этого каталога в PATH нет. Владелец видел «agy executable not found» при установленной и работающей утилите — which agy находил её по адресу /home/ochenstarik/.local/bin/agy. Добавлены стандартные места Linux: ~/.local/bin, /usr/local/bin, /usr/bin, /snap/bin. Сообщение об отказе перечисляет проверенные пути. Отдельно различается «нет доступа к каталогу» и «файла нет». Ollama Cloud. Мастер требовал адрес локального сервера, которого у облачного аккаунта не существует: там только ключ. Теперь при заданном ключе недоступность локального адреса не считается отказом — аккаунт проверяется по каталогу ollama.com и принимается с честной оговоркой, что локальные модели недоступны. Проверено: 19 моделей каталога. Без ключа поведение прежнее. 609 passed, ruff clean, релизный гейт 10/10. Co-Authored-By: Claude Opus 5 --- src/antigravity_provider/agy_subprocess.py | 33 +++++++++++++---- .../router/connection_preflight.py | 35 +++++++++++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/antigravity_provider/agy_subprocess.py b/src/antigravity_provider/agy_subprocess.py index bf59474..2961a29 100644 --- a/src/antigravity_provider/agy_subprocess.py +++ b/src/antigravity_provider/agy_subprocess.py @@ -46,12 +46,32 @@ def _find_agy_exe() -> str: if env and Path(env).is_file(): return env - # 2. Standard location based on hermes home parent + # 2. Стандартные места установки. + # + # Раньше проверялась только раскладка Windows (%LOCALAPPDATA%/agy/bin), а в + # Linux agy ставится в ~/.local/bin. Хаб запускается с урезанным окружением, + # где этого каталога в PATH нет, и вход в Antigravity падал с «agy executable + # not found» при установленной и работающей утилите. from antigravity_provider.paths import get_hermes_home + exe_name = "agy.exe" if os.name == "nt" else "agy" - candidate = get_hermes_home().parent / "agy" / "bin" / exe_name - if candidate.is_file(): - return str(candidate) + candidates = [ + get_hermes_home().parent / "agy" / "bin" / exe_name, + Path.home() / ".local" / "bin" / exe_name, + Path("/usr/local/bin") / exe_name, + Path("/usr/bin") / exe_name, + Path("/snap/bin") / exe_name, + ] + checked = [] + for candidate in candidates: + checked.append(str(candidate)) + try: + if candidate.is_file(): + return str(candidate) + except OSError as exc: + # Каталог может быть закрыт правами: это «не смогли проверить», + # а не «файла нет». + checked[-1] = f"{candidate} (нет доступа: {exc.strerror or exc})" # 3. PATH found = shutil.which("agy") or shutil.which("agy.exe") @@ -59,8 +79,9 @@ def _find_agy_exe() -> str: return found raise FileNotFoundError( - "agy executable not found. Set the AGY_EXE_PATH environment " - "variable, install agy, or ensure it is on PATH." + "Утилита agy не найдена. Проверено: " + + "; ".join(checked) + + "; и PATH процесса. Задайте путь переменной AGY_EXE_PATH либо установите agy." ) diff --git a/src/antigravity_provider/router/connection_preflight.py b/src/antigravity_provider/router/connection_preflight.py index e1baee9..951b716 100644 --- a/src/antigravity_provider/router/connection_preflight.py +++ b/src/antigravity_provider/router/connection_preflight.py @@ -97,6 +97,41 @@ def validate_connection(provider, token="", base_url="", preferred_model=""): except Exception as exc: exc_str = str(exc) exc_lower = exc_str.lower() + # У Ollama Cloud локального сервера нет вовсе: там только ключ. Если + # ключ задан, а локальный адрес не отвечает, это не отказ подключения — + # аккаунт облачный. Проверяем его по общедоступному каталогу и + # принимаем, честно сказав, что локальные модели недоступны. + if provider == "ollama" and token and any( + k in exc_lower for k in ("connection refused", "winerror 10061", "errno 111", "refused", "failed to connect", "target machine actively refused") + ): + try: + cloud = urllib.request.Request( + "https://ollama.com/api/tags", + headers={"Accept": "application/json", "Authorization": f"Bearer {token}"}, + ) + with urllib.request.urlopen(cloud, timeout=15) as response: + payload = json.load(response) + cloud_models = sorted({ + m.get("name") for m in (payload.get("models") or []) + if isinstance(m, dict) and isinstance(m.get("name"), str) + }) + return { + "ok": True, + "message": ( + f"Облачный аккаунт Ollama принят. Моделей в каталоге: {len(cloud_models)}. " + f"Локальный сервер по адресу {base_url} не отвечает — локальные модели недоступны." + ), + "data": {"models": cloud_models, "base_url": base_url, "cloud_only": True}, + } + except Exception as cloud_exc: + return { + "ok": False, + "message": ( + f"Локальный сервер {base_url} не отвечает, и облачный каталог Ollama недоступен: " + f"{str(cloud_exc)[:160]}" + ), + "data": {"models": []}, + } if provider == "ollama" and any(k in exc_lower for k in ("connection refused", "winerror 10061", "errno 111", "111", "refused", "failed to connect", "target machine actively refused")): return { "ok": False, From eeeed36358fafe12df97f7436f5c7d8ab45ab5ae Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Tue, 1 Sep 2026 02:12:22 +0700 Subject: [PATCH 04/14] =?UTF-8?q?fix(installer):=20=D1=83=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=BD=D0=B0=20Linux=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BE=D1=81=D1=82=D0=B0=D0=BD=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B8=D0=B2=D0=B0=D0=BB=D0=B0=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D1=8E=D1=89=D0=B8=D0=B9=20=D1=85=D0=B0=D0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Установщик копировал файлы, но работающий сервер не трогал. Процесс продолжал выполнять прежний код из памяти, и владелец видел старый интерфейс при новом номере сборки: раздача статики читает файлы с диска, а вся логика действий живёт в загруженном модуле. Три сборки подряд ставились в файлы, но не в работу — отсюда «сброс не работает», «очистка не работает», «версия не изменилась». Добавлен шаг 0: остановка процессов хаба до копирования. Ищутся только процессы текущего пользователя и только по признакам хаба (antigravity_provider.router.web, hermes_hub_web_entry). Сначала обычное завершение, десять секунд ожидания, затем принудительное. Если процессы всё же остались, установка НЕ прерывается — файлы обновляются, а владельцу сообщается, что старый код продолжит работать, пока он их не снимет. В итоговом сообщении сказано, что хаб остановлен и его надо запустить заново, и дана команда для проверки, что поднялся новый код. 609 passed, ruff clean. Co-Authored-By: Claude Opus 5 --- installer/install-linux.sh | 61 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/installer/install-linux.sh b/installer/install-linux.sh index cd1bb27..957f848 100644 --- a/installer/install-linux.sh +++ b/installer/install-linux.sh @@ -22,6 +22,60 @@ echo "Hermes Home : $HERMES_HOME" echo "Source Root : $REPO_ROOT" echo "" +# 0. Остановка работающего хаба. +# +# Установщик копировал файлы, но работающий сервер не трогал: он продолжал +# крутить старый код в памяти, и владелец видел прежний интерфейс при новом +# номере сборки. Три сборки подряд ставились в файлы, но не в работу. +echo "[0/6] Остановка работающего Hermes Hub..." + +stop_running_hub() { + local pattern="antigravity_provider.router.web|hermes_hub_web_entry" + local pids + # Только процессы ЭТОГО пользователя и только те, что относятся к хабу. + pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr ' +' ' ')" + + if [ -z "$pids" ]; then + echo " Работающий хаб не найден — останавливать нечего." + return 0 + fi + + echo " Найдены процессы хаба: $pids" + # shellcheck disable=SC2086 + kill $pids 2>/dev/null || true + + local waited=0 + while [ "$waited" -lt 10 ]; do + sleep 1 + waited=$((waited + 1)) + pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr ' +' ' ')" + [ -z "$pids" ] && break + done + + if [ -n "$pids" ]; then + echo " Не завершились за 10 секунд, снимаю принудительно: $pids" + # shellcheck disable=SC2086 + kill -9 $pids 2>/dev/null || true + sleep 1 + pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr ' +' ' ')" + fi + + if [ -n "$pids" ]; then + # Не прерываем установку: файлы обновятся, а владельцу скажем правду. + echo " ⚠ Остались процессы: $pids. Снимите их вручную, иначе будет работать старый код." + return 1 + fi + + echo " Хаб остановлен." + return 0 +} + +stop_running_hub || true +echo "" + # 1. Check / Discover Python Runtime and Hermes Environment echo "[1/6] Checking Python and Hermes environment..." PYTHON_BIN="" @@ -203,5 +257,12 @@ echo "To launch the Web Application window:" echo " $LAUNCHER_BIN" echo "" echo "Or open 'Hermes Hub Web' from your Applications menu." +echo "" +echo "ВАЖНО: работавший хаб был остановлен перед установкой, иначе он" +echo "продолжал бы выполнять прежний код из памяти. Запустите его заново" +echo "командой выше — только тогда новая сборка начнёт работать." +echo "" +echo "Проверить, что поднялся новый код:" +echo " curl -s -D - -o /dev/null http://127.0.0.1:5800/workspace.js | grep -i cache-control" echo "======================================================================" exit 0 From 2bbb9db5de92452d87f43505af63247db50a38eb Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Tue, 1 Sep 2026 02:34:13 +0700 Subject: [PATCH 05/14] =?UTF-8?q?fix(wizard):=20=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=20=D0=B1=D1=80=D0=B0=D0=BB=D1=81=D1=8F=20=D0=B8=D0=B7=20=D0=B3?= =?UTF-8?q?=D0=BB=D0=BE=D0=B1=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B9=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD=D0=BE=D0=B9,=20?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D0=B8=D0=B7=20=D0=BF=D0=BE=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При проверке подключения ключ читался из window._wiz_token. Переменная переживает предыдущие попытки подключения и могла оказаться пустой или от другого провайдера: OpenRouter отвечал «HTTP 401: Missing Authentication header» при заполненном поле, и причина была не видна. Прямой вызов validate_connection с ключом даёт «401: User not found», то есть заголовок формируется верно — дело было в источнике значения. Теперь ключ и адрес читаются из полей в момент проверки. 609 passed, ruff clean. Co-Authored-By: Claude Opus 5 --- src/antigravity_provider/router/web/static/app.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index 91880d0..184baec 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -3127,7 +3127,15 @@ async function proceedToWizardStep3(providerId) { if (tokenInput || baseInput) { const feedback = document.getElementById('modal-feedback-area'); if (feedback) feedback.textContent = 'Проверка подключения и запрос моделей…'; - const result = await executeAction('validate_connection', {provider: providerId, token: window._wiz_token || '', base_url: window._wiz_base_url || ''}); + // Ключ читаем из поля прямо сейчас, а не из глобальной переменной: + // она переживает предыдущие попытки подключения и может оказаться пустой + // или чужой. Провайдер тогда отвечает «Missing Authentication header» при + // заполненном поле, и владелец не понимает, в чём дело. + const liveToken = (tokenInput && tokenInput.value.trim()) || window._wiz_token || ''; + const liveBase = (baseInput && baseInput.value.trim()) || window._wiz_base_url || ''; + window._wiz_token = liveToken; + window._wiz_base_url = liveBase; + const result = await executeAction('validate_connection', {provider: providerId, token: liveToken, base_url: liveBase}); if (!result?.ok) { if (feedback) feedback.textContent = result?.message || 'Нет ответа от сервера'; return; From afaf600f565792c03f5e344443c0af5f5e1c1a52 Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Tue, 1 Sep 2026 02:45:01 +0700 Subject: [PATCH 06/14] =?UTF-8?q?feat(nvidia):=20=D0=BE=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D0=B9,=20=D0=B4=D0=BE=D1=81=D1=82=D1=83?= =?UTF-8?q?=D0=BF=D0=BD=D1=8B=D1=85=20=D0=BA=D0=BE=D0=BD=D0=BA=D1=80=D0=B5?= =?UTF-8?q?=D1=82=D0=BD=D0=BE=D0=BC=D1=83=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83?= =?UTF-8?q?=D0=BD=D1=82=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Каталог NVIDIA публичный: GET /v1/models отдаётся вообще без ключа и возвращает одни и те же 83 модели всем. Проверено запросом. Полей о доступности в нём нет — только id, object, created, owned_by. Значит узнать из каталога, чем может пользоваться аккаунт, невозможно. Единственный достоверный способ — спросить у самой модели. Недоступная отвечает «404 Function ... Not found for account » до генерации, то есть её проверка ничего не стоит; доступная расходует один токен при max_tokens=1. Добавлены модуль model_entitlements, действие probe_account_models и кнопка «Определить доступные модели» в карточке аккаунта для NVIDIA и OpenRouter. Запуск только по явному нажатию с подтверждением: опрос тратит вызовы и упирается в ограничения частоты. Результат сохраняется. Модели раскладываются на три группы, а не на две: доступные, не выданные аккаунту и НЕ ОПРЕДЕЛЁННЫЕ с причиной. Отвергнутый ключ (401/403), превышение частоты (429) и сетевой сбой попадают в третью группу — выдавать их за «недоступно» нельзя. Проверено: без ключа все модели получают «ключ не задан», с неверным ключом — «ключ отвергнут (HTTP 403)». 610 passed, ruff clean, релизный гейт 10/10. Co-Authored-By: Claude Opus 5 --- .../router/action_handler.py | 44 +++++ .../router/model_entitlements.py | 180 ++++++++++++++++++ .../router/web/static/app.js | 14 ++ 3 files changed, 238 insertions(+) create mode 100644 src/antigravity_provider/router/model_entitlements.py diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index 7198a51..1c1e794 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -707,6 +707,50 @@ class ActionExecutor: return {'ok': False, 'message': reason, 'data': {'status': status}} return {'ok': True, 'message': 'Ожидание подтверждения', 'data': {'status': status}} + if action == 'probe_account_models': + # Определение доступных аккаунту моделей. Запускается только по + # явному действию владельца: каталог NVIDIA публичный и о правах + # аккаунта ничего не сообщает, поэтому доступность выясняется + # опросом, а он тратит вызовы и упирается в ограничения частоты. + from .model_entitlements import probe_account_models, load_entitlements + from .model_discovery_service import ModelDiscoveryService + + pid = data.get('profile_id') or '' + prov_norm = (prov or data.get('provider') or '').strip().lower() + if not pid or not prov_norm: + return {'ok': False, 'message': 'Не указан профиль или провайдер'} + + if data.get('cached_only'): + cached = load_entitlements(prov_norm, pid) + if not cached: + return {'ok': True, 'message': 'Н/Д: доступность моделей ещё не определялась', 'data': {}} + return {'ok': True, 'message': 'Сохранённый результат', 'data': cached} + + auth = ProfileAuthManager.load_profile_auth(prov_norm, pid) or {} + token = (auth.get('api_key') or auth.get('token') or '').strip() + if not token: + return {'ok': False, 'message': f'У профиля {pid} нет сохранённого API-ключа'} + + pcfg = load_router_config().get_profile(pid) + base_url = (getattr(pcfg, 'custom_base_url', None) or auth.get('base_url') or '').strip() + if not base_url: + base_url = 'https://integrate.api.nvidia.com/v1' if prov_norm.startswith('nvidia') else '' + if not base_url: + return {'ok': False, 'message': f'Не известен адрес провайдера для {pid}'} + + models = ModelDiscoveryService.get().get_models(prov_norm) or [] + if not models: + return {'ok': False, 'message': 'Каталог моделей ещё не получен — сначала запросите список'} + + res = probe_account_models(prov_norm, pid, token, base_url, models) + d = res.to_dict() + msg = ( + f"Доступно {len(res.available)} из {d['total']}; " + f"не выдано {len(res.unavailable)}; " + f"не определено {len(res.undetermined)}" + ) + return {'ok': True, 'message': msg, 'data': d} + if action == 'validate_connection': from .connection_preflight import validate_connection return validate_connection(prov, data.get('token') or data.get('api_key') or '', data.get('base_url') or '', data.get('preferred_model') or '') diff --git a/src/antigravity_provider/router/model_entitlements.py b/src/antigravity_provider/router/model_entitlements.py new file mode 100644 index 0000000..bb33456 --- /dev/null +++ b/src/antigravity_provider/router/model_entitlements.py @@ -0,0 +1,180 @@ +"""Определение моделей, доступных конкретному аккаунту. + +Каталог NVIDIA публичный: `GET /v1/models` отдаётся вообще без ключа и +возвращает один и тот же список всем. Полей о доступности в нём нет — только +`id`, `object`, `created`, `owned_by`. Проверено запросом. + +Значит узнать, чем может пользоваться конкретный аккаунт, из каталога нельзя. +Единственный достоверный способ — спросить у самой модели. Недоступная +отвечает до генерации: + + 404 Function ... Not found for account '' + +то есть проверка недоступной модели не стоит ничего, а доступной — один токен +при `max_tokens=1`. + +Опрос запускается ТОЛЬКО по явному действию владельца и результат сохраняется: +83 запроса подряд упираются в ограничения провайдера, и делать это молча при +каждом подключении неправильно. +""" + +from __future__ import annotations + +import json +import logging +import threading +import time +import urllib.error +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional + +from antigravity_provider import paths + +logger = logging.getLogger("hermes.router.entitlements") + +# Опрашиваем небольшими пачками: провайдеры ограничивают частоту, а выигрыш от +# большего числа потоков всё равно съедается их лимитом. +DEFAULT_WORKERS = 8 +DEFAULT_TIMEOUT_SEC = 20.0 + + +@dataclass +class EntitlementResult: + """Итог опроса одного аккаунта.""" + + provider: str + profile_id: str + checked_at: float + available: List[str] = field(default_factory=list) + unavailable: List[str] = field(default_factory=list) + undetermined: Dict[str, str] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + return { + "provider": self.provider, + "profile_id": self.profile_id, + "checked_at": self.checked_at, + "available": list(self.available), + "unavailable": list(self.unavailable), + "undetermined": dict(self.undetermined), + "total": len(self.available) + len(self.unavailable) + len(self.undetermined), + } + + +def _store_path() -> Any: + return paths.get_hermes_home() / "model_entitlements.json" + + +def load_entitlements(provider: str, profile_id: str) -> Optional[Dict[str, Any]]: + """Ранее определённая доступность или None, если опрос не проводился.""" + try: + path = _store_path() + if not path.is_file(): + return None + data = json.loads(path.read_text(encoding="utf-8-sig")) + return data.get(f"{provider}:{profile_id}") + except Exception as exc: + logger.debug("Не удалось прочитать сохранённую доступность: %s", exc) + return None + + +def save_entitlements(result: EntitlementResult) -> None: + path = _store_path() + try: + data: Dict[str, Any] = {} + if path.is_file(): + try: + data = json.loads(path.read_text(encoding="utf-8-sig")) + except Exception: + data = {} + data[f"{result.provider}:{result.profile_id}"] = result.to_dict() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + except Exception as exc: + logger.warning("Не удалось сохранить доступность моделей: %s", exc) + + +def _probe_one(base_url: str, token: str, model: str, timeout: float) -> tuple: + """Вернуть (model, state, note): state — available | unavailable | undetermined.""" + body = json.dumps( + {"model": model, "messages": [{"role": "user", "content": "ping"}], "max_tokens": 1} + ).encode() + req = urllib.request.Request( + base_url.rstrip("/") + "/chat/completions", + data=body, + headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"}, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as response: + payload = json.load(response) + if isinstance(payload.get("choices"), list): + return (model, "available", "") + return (model, "undetermined", "провайдер вернул ответ без choices") + except urllib.error.HTTPError as exc: + # 404 — модель аккаунту не выдана. Это ответ провайдера, а не сбой. + if exc.code == 404: + return (model, "unavailable", "не выдана аккаунту") + # 401/403 — отвергнут сам ключ: дальше опрашивать бессмысленно. + if exc.code in (401, 403): + return (model, "undetermined", f"ключ отвергнут (HTTP {exc.code})") + if exc.code == 429: + return (model, "undetermined", "превышена частота запросов") + return (model, "undetermined", f"HTTP {exc.code}") + except Exception as exc: + return (model, "undetermined", str(exc)[:80]) + + +def probe_account_models( + provider: str, + profile_id: str, + token: str, + base_url: str, + models: List[str], + workers: int = DEFAULT_WORKERS, + timeout: float = DEFAULT_TIMEOUT_SEC, + progress: Optional[Callable[[int, int], None]] = None, +) -> EntitlementResult: + """Опросить каталог и разложить модели по доступности. + + Модель, про которую ответ неоднозначен, попадает в `undetermined` с + причиной — выдавать её за доступную или недоступную нельзя. + """ + result = EntitlementResult( + provider=provider, profile_id=profile_id, checked_at=time.time() + ) + if not models: + return result + if not token: + result.undetermined = {m: "ключ не задан" for m in models} + return result + + done = 0 + lock = threading.Lock() + + def _run(model: str): + nonlocal done + outcome = _probe_one(base_url, token, model, timeout) + with lock: + done += 1 + if progress: + try: + progress(done, len(models)) + except Exception: + pass + return outcome + + with ThreadPoolExecutor(max_workers=max(1, workers)) as pool: + for model, state, note in pool.map(_run, models): + if state == "available": + result.available.append(model) + elif state == "unavailable": + result.unavailable.append(model) + else: + result.undetermined[model] = note + + result.available.sort() + result.unavailable.sort() + save_entitlements(result) + return result diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index 184baec..15ac56a 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -880,9 +880,23 @@ function renderAccountCheck(profile) {
Каталог моделей (${models.length})
${profile.provider === 'ollama' ? `

Выше — модели указанного сервера Ollama.

Облачный каталог Ollama: ${meta.cloud?.error ? 'Н/Д — ' + escapeHtml(meta.cloud.error) : meta.cloud?.models ? escapeHtml(meta.cloud.models.join(', ')) : 'Н/Д — ещё не получен'}

Доступ аккаунта к облачным моделям: Н/Д до успешного вызова. Для прямого вызова нужен API-ключ Ollama; для локального клиента — вход через ollama signin.

` : ''} + ${['nvidia', 'nvidia-nim', 'openrouter'].includes(String(profile.provider || '').toLowerCase()) + ? `` + : ''} `; } +// Каталог NVIDIA и OpenRouter публичный: он одинаков у всех и о правах +// аккаунта ничего не говорит. Доступность выясняется опросом моделей, а он +// тратит вызовы — поэтому только по явному нажатию, и результат сохраняется. +async function handleProbeAccountModels(profileId, provider) { + if (!confirm('Хаб опросит каталог провайдера, чтобы выяснить, какие модели доступны этому аккаунту. Каталог общий для всех и о правах не сообщает, поэтому каждая модель проверяется отдельным запросом. Недоступная отвечает отказом и ничего не стоит, доступная расходует один токен. Продолжить?')) return; + showToast('Опрос моделей начат. Это может занять около минуты.', 'info'); + const res = await executeAction('probe_account_models', { profile_id: profileId, provider: provider }); + showToast(res?.message || 'Нет ответа от сервера', res?.ok ? 'success' : 'error'); + await fetchSnapshot(); +} + async function handleAccountProbe(profileId) { await executeAction('check_account', {profile_id: profileId}); await fetchSnapshot(); From 9761362bc0ad0f016c6abc7c5b0397bd68ef6d8b Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Tue, 1 Sep 2026 02:55:39 +0700 Subject: [PATCH 07/14] =?UTF-8?q?fix(ollama):=20=D0=BE=D0=B1=D0=BB=D0=B0?= =?UTF-8?q?=D1=87=D0=BD=D1=8B=D0=B9=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD?= =?UTF-8?q?=D1=82=20=D0=BE=D0=B1=D1=8A=D1=8F=D0=B2=D0=BB=D1=8F=D0=BB=D1=81?= =?UTF-8?q?=D1=8F=20=D0=BD=D0=B5=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D1=8E?= =?UTF-8?q?=D1=89=D0=B8=D0=BC=20=D0=B8=20=D0=B1=D0=B5=D0=B7=D0=BB=D0=B8?= =?UTF-8?q?=D0=BC=D0=B8=D1=82=D0=BD=D1=8B=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Адрес. Адаптер всегда шёл на 127.0.0.1:11434, получал Connection refused и помечал аккаунт нерабочим — при том что каталог из девятнадцати облачных моделей у него получался. У Ollama Cloud локального сервера нет вовсе, есть только ключ. Теперь при заданном ключе и незаданном адресе адаптер идёт на ollama.com; явно указанный адрес по-прежнему в приоритете. Проверено: только ключ даёт https://ollama.com, заданный адрес — его же. Ключ. Как у NVIDIA и OpenRouter, он читался только из auth_config в router_profiles.yaml, куда мастер его не кладёт. Добавлено чтение из хранилища учётных данных. Лимиты. Провайдер ollama безусловно относился к локальным и получал подпись «Без ограничений (локальная модель)». Для облачного аккаунта это неправда: лимиты у него есть. Теперь облачный аккаунт (узнаётся по наличию ключа) показывает «Н/Д: лимиты не измерены — провайдер не сообщает их через API». Отдельно: отказ agy теперь доносит причину. Прежнее «код 1; каталог не получен» скрывало и текст ошибки, и главное — что agy запускается с HOME, подменённым на каталог профиля ради изоляции учётных данных. Если вход делался обычным agy в оболочке, ключи легли в настоящий домашний каталог, и профиль пуст. Теперь в сообщении и ответ agy, и использованный HOME. 610 passed, ruff clean, релизный гейт 10/10. Co-Authored-By: Claude Opus 5 --- src/antigravity_provider/agy_subprocess.py | 13 +++++- .../router/adapters/ollama_adapter.py | 40 ++++++++++++++++++- .../router/quota_collector.py | 25 ++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/antigravity_provider/agy_subprocess.py b/src/antigravity_provider/agy_subprocess.py index 2961a29..d2d42e8 100644 --- a/src/antigravity_provider/agy_subprocess.py +++ b/src/antigravity_provider/agy_subprocess.py @@ -228,7 +228,18 @@ def discover_models(profile_id: str | None = None) -> dict[str, str]: raw = result.stdout.strip() if not raw or result.returncode != 0: if profile_id: - raise RuntimeError(f"agy models: код {result.returncode}; каталог не получен") + # Прежнее сообщение «код 1; каталог не получен» скрывало причину. + # agy пишет её в stderr, и без неё непонятно главное: он + # запускается с HOME, подменённым на каталог профиля (ради + # изоляции учётных данных между аккаунтами). Если вход + # выполнялся обычным agy в оболочке, ключи легли в настоящий + # домашний каталог, и профиль пуст — отсюда отказ. + detail = (result.stderr or "").strip() or (result.stdout or "").strip() + detail = detail.splitlines()[-1][:300] if detail else "вывод пуст" + raise RuntimeError( + f"agy models: код {result.returncode}. Ответ agy: {detail}. " + f"Запуск с HOME={env.get('HOME') or env.get('USERPROFILE') or 'не задан'}" + ) return dict(_AGY_MODEL_CACHE or {}) except Exception: if profile_id: diff --git a/src/antigravity_provider/router/adapters/ollama_adapter.py b/src/antigravity_provider/router/adapters/ollama_adapter.py index dd567e4..90f1bf4 100644 --- a/src/antigravity_provider/router/adapters/ollama_adapter.py +++ b/src/antigravity_provider/router/adapters/ollama_adapter.py @@ -15,6 +15,7 @@ from .local_adapter import LocalLLMAdapter logger = logging.getLogger("hermes.router.adapter.ollama") DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434" +CLOUD_OLLAMA_BASE_URL = "https://ollama.com" DEFAULT_OLLAMA_MODELS = ["llama3:latest"] @@ -25,11 +26,34 @@ class OllamaAdapter(LocalLLMAdapter): and native Ollama endpoints (/api/tags). """ + def _stored_base_url(self, profile: RouterProfileConfig) -> str: + """Адрес из хранилища учётных данных, если владелец его задавал.""" + try: + from antigravity_provider.router.profile_manager import ProfileAuthManager + + stored = ProfileAuthManager.load_profile_auth(profile.provider, profile.profile_id) or {} + return str(stored.get("base_url") or "").strip() + except Exception: + return "" + def _resolve_base_url(self, profile: RouterProfileConfig) -> str: - """Resolve base_url from profile custom_base_url, auth_config, or environment.""" - url = ( + """Адрес сервера: локальный либо облачный. + + У аккаунта Ollama Cloud локального сервера нет вовсе — только ключ. + Раньше адаптер всегда шёл на 127.0.0.1:11434, получал «Connection + refused» и объявлял облачный аккаунт неработающим, хотя каталог из + девятнадцати моделей у него получался. + """ + explicit = ( profile.custom_base_url or profile.auth_config.get("base_url") + or self._stored_base_url(profile) + ) + if not explicit and self._resolve_api_key(profile): + return CLOUD_OLLAMA_BASE_URL + + url = ( + explicit or os.environ.get("OLLAMA_BASE_URL") or os.environ.get("OLLAMA_HOST") or DEFAULT_OLLAMA_BASE_URL @@ -45,6 +69,18 @@ class OllamaAdapter(LocalLLMAdapter): if key: return str(key).strip() + # Ключ, введённый в мастере, лежит в хранилище учётных данных, а не в + # auth_config из router_profiles.yaml. + try: + from antigravity_provider.router.profile_manager import ProfileAuthManager + + stored = ProfileAuthManager.load_profile_auth(profile.provider, profile.profile_id) or {} + key = stored.get("api_key") or stored.get("token") + if key: + return str(key).strip() + except Exception: + pass + suffix = profile.profile_id.upper().replace("-", "_") for candidate in (f"OLLAMA_API_KEY_{suffix}", "OLLAMA_API_KEY", "OLLAMA_TOKEN"): val = os.environ.get(candidate, "").strip() diff --git a/src/antigravity_provider/router/quota_collector.py b/src/antigravity_provider/router/quota_collector.py index 1c42a12..8dfc607 100644 --- a/src/antigravity_provider/router/quota_collector.py +++ b/src/antigravity_provider/router/quota_collector.py @@ -918,6 +918,31 @@ class AccountQuotaService: def _generate_baseline_snapshot(self, provider: str, profile_id: str) -> QuotaSnapshot: """Truthful offline baseline with provider-specific independent limit pools.""" now = _utc_now() + # У облачного аккаунта Ollama лимиты есть, и объявлять их + # отсутствующими нельзя. Локальный сервер узнаётся по отсутствию + # ключа: облачный доступ без ключа невозможен. + if provider == "ollama": + try: + from .profile_manager import ProfileAuthManager + + auth = ProfileAuthManager.load_profile_auth(provider, profile_id) or {} + is_cloud = bool((auth.get("api_key") or auth.get("token") or "").strip()) + except Exception: + is_cloud = False + if is_cloud: + return QuotaSnapshot( + account_id=profile_id, + provider=provider, + buckets=[], + fetched_at=now, + source="cloud_provider", + unavailable_reason=( + "Н/Д: лимиты облачного аккаунта Ollama не измерены — " + "провайдер не сообщает их через API" + ), + is_loading=False, + ) + if provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm"): b_unlimited = QuotaBucket( id="local.unlimited", From 9c57a7c607d129072a29b14705ad2b6a556e095b Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Tue, 1 Sep 2026 03:05:44 +0700 Subject: [PATCH 08/14] =?UTF-8?q?fix(antigravity):=20=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=87=D0=B8=D0=B9=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD?= =?UTF-8?q?=D1=82=20=D0=BF=D0=B0=D0=B4=D0=B0=D0=BB=20=D0=B8=D0=B7-=D0=B7?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=D0=B7=D0=B0=D0=B4=D0=B0=D0=BD=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D1=8F=20=D1=83?= =?UTF-8?q?=D1=81=D0=B8=D0=BB=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новое сообщение об отказе agy показало причину. У рабочего аккаунта victor.trushenko@gmail.com каталог получался (11 моделей), а вызов падал: invalid model selection (--model "gemini-3.7-flash" --effort ""): gemini-3.7-flash requires --effort (available: low, medium, high) Каталог agy отдаёт идентификаторы с уровнем усилия внутри имени (gemini-3.7-flash-high, -medium, -low), а в профиле хранится голое имя. Вызов уходил с пустым --effort, и agy отказывался работать. Теперь уровень определяется: если он зашит в имени, отделяется от модели; если нет — берётся из каталога, предпочтительно medium. Проверено, что claude-sonnet-4-6 при этом не разбирается ошибочно: -6 уровнем не является. Отдельно: восемь профилей Antigravity отвечают «Please sign in to view available models» при HOME=~/.hermes/agy_profiles/. Это пустые заготовки без учётных данных, а не поломка: agy запускается с подменённым HOME ради изоляции аккаунтов, и в незаполненном каталоге ключей нет. Подписочные аккаунты Codex и Claude: отказ по API-ключу теперь объясняет, что такие аккаунты подключаются входом по ссылке, а не ключом. Проверка обращается к каталогу моделей, где платформенному ключу нужны права api.model.read, и подписочный токен там получает 401 или 403 при исправном аккаунте. 610 passed, ruff clean, релизный гейт 10/10. Co-Authored-By: Claude Opus 5 --- src/antigravity_provider/agy_subprocess.py | 25 +++++++++++++++++++ .../router/connection_preflight.py | 18 ++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/antigravity_provider/agy_subprocess.py b/src/antigravity_provider/agy_subprocess.py index d2d42e8..1769767 100644 --- a/src/antigravity_provider/agy_subprocess.py +++ b/src/antigravity_provider/agy_subprocess.py @@ -756,6 +756,31 @@ def agy_generate( "--disable-slash-commands", "--print-timeout", f"{timeout}s", ] + # Каталог agy отдаёт идентификаторы с уровнем усилия: gemini-3.7-flash-high, + # -medium, -low. В профиле же хранится голое имя, и вызов уходил с пустым + # --effort: agy отвечал «gemini-3.7-flash requires --effort (available: low, + # medium, high)» и работа не начиналась. + if agy_model and not agy_effort: + base, _, tail = str(agy_model).rpartition("-") + if base and tail in ("low", "medium", "high"): + # Уровень зашит в самом имени — отделяем его. + agy_model, agy_effort = base, tail + else: + known = _AGY_EFFORT_MAP.get(agy_model) or set() + if known: + # Берём средний уровень, если он есть: он и по названию средний, + # и по расходу квоты. Иначе — любой доступный, по порядку. + for candidate in ("medium", "high", "low"): + if candidate in known: + agy_effort = candidate + break + if not agy_effort: + agy_effort = sorted(known)[0] + logger.info( + "agy_generate: у модели %s не задан уровень усилия, выбран %s из %s", + agy_model, agy_effort, sorted(known), + ) + if agy_model: cmd.extend(["--model", agy_model]) if agy_effort: diff --git a/src/antigravity_provider/router/connection_preflight.py b/src/antigravity_provider/router/connection_preflight.py index 951b716..d4ebb21 100644 --- a/src/antigravity_provider/router/connection_preflight.py +++ b/src/antigravity_provider/router/connection_preflight.py @@ -30,6 +30,20 @@ def validate_connection(provider, token="", base_url="", preferred_model=""): raise ValueError("Для этого провайдера используйте вход через авторизацию") if provider not in ("local", "vllm", "ollama") and not token: raise ValueError("Не указан API-ключ") + + # Подписочные аккаунты Codex (ChatGPT) и Claude работают по OAuth, а не + # по платформенному ключу. Проверка ниже обращается к каталогу моделей, + # где платформенному ключу нужны права api.model.read: подписочный + # токен там получает 403 или 401, хотя аккаунт исправен и в Hermes + # работает. Предупреждаем об этом заранее, чтобы отказ не выглядел + # поломкой аккаунта. + subscription_note = "" + if provider in ("openai-codex", "codex", "claude", "anthropic"): + subscription_note = ( + " Если это аккаунт по подписке (ChatGPT Plus/Pro или Claude Max), " + "ключ здесь не подойдёт: такие аккаунты подключаются входом по ссылке " + "на предыдущем шаге мастера, а не API-ключом." + ) headers = {"Content-Type": "application/json"} if provider == "claude": headers.update({"x-api-key": token, "anthropic-version": "2023-06-01"}) @@ -139,7 +153,9 @@ def validate_connection(provider, token="", base_url="", preferred_model=""): "data": {"models": []}, } if isinstance(exc, urllib.error.HTTPError): - reason = exc.reason or 'провайдер отклонил запрос' + reason = (exc.reason or 'провайдер отклонил запрос') + if exc.code in (401, 403) and subscription_note: + reason = str(reason) + subscription_note try: body = json.loads(exc.read(4096).decode('utf-8', errors='replace')) detail = body.get('error') or body.get('detail') From 6af388a5dcd407e49f5515a171739db4d61a66c4 Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Tue, 1 Sep 2026 03:17:01 +0700 Subject: [PATCH 09/14] =?UTF-8?q?fix(antigravity):=20=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D0=B9=20=D0=B2=D1=85=D0=BE=D0=B4=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=B4=D0=B0=D0=B2=D0=B0=D0=BB=D1=81=D1=8F=20=D0=B7=D0=B0=20?= =?UTF-8?q?=D1=83=D1=81=D0=BF=D0=B5=D1=88=D0=BD=D1=8B=D0=B9;=20=D0=B4?= =?UTF-8?q?=D0=B0=D1=82=D0=B0=20=D1=83=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B2=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84?= =?UTF-8?q?=D0=B5=D0=B9=D1=81=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вход. agy читает учётные данные из <каталог профиля>/.gemini/oauth_creds.json. Хаб этот файл пишет, но при отсутствии токена доступа создавал его пустым, а сбой записи только заносился в журнал. Владелец видел «подключено», а проверка отвечала «Please sign in to view available models» — ровно это и случилось с профилем ag-2 при заново подключённом victor.trushenko@gmail.com. Теперь вход без токена доступа отвергается с причиной, а несостоявшаяся запись учётных данных не позволяет считать аккаунт подключённым. Проверено: данные без токена отклоняются, с токеном проходят. Версия. Между сборками она не меняется намеренно, а коммит — строка из шестнадцатеричных цифр, по которой на глаз не отличить старую сборку от новой. В /api/settings добавлено время установки из манифеста, и оно показывается рядом с версией: «Hermes Hub Web v0.1.2 · 01.09 04:10». 610 passed, ruff clean, релизный гейт 10/10. Co-Authored-By: Claude Opus 5 --- .../router/profile_manager.py | 19 ++++++++++++++++++- src/antigravity_provider/router/web/server.py | 6 +++++- .../router/web/static/app.js | 19 +++++++++++++++++-- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/antigravity_provider/router/profile_manager.py b/src/antigravity_provider/router/profile_manager.py index 174a1c8..912a268 100644 --- a/src/antigravity_provider/router/profile_manager.py +++ b/src/antigravity_provider/router/profile_manager.py @@ -123,6 +123,14 @@ class ProfileAuthManager: access_token = token_info.get("access_token") or auth_data.get("access_token") or "" refresh_token = token_info.get("refresh_token") or auth_data.get("refresh_token") or "" + # Файл без токена доступа бесполезен: agy на нём отвечает «Please sign in + # to view available models», а хаб считает аккаунт подключённым. Пустой + # вход не должен выдаваться за успешный. + if not access_token and not refresh_token: + raise ValueError( + "Вход не завершён: провайдер не вернул токен доступа. " + "Учётные данные agy не записаны." + ) scope = token_info.get("scope") or auth_data.get("scope") or "" token_type = token_info.get("token_type") or auth_data.get("token_type") or "Bearer" id_token = token_info.get("id_token") or auth_data.get("id_token") or "" @@ -314,7 +322,16 @@ class ProfileAuthManager: try: cls.write_agy_oauth_creds(pdir, auth_data) except Exception as e: - logger.warning("Failed to write .gemini/oauth_creds.json for profile=%s: %s", profile_id, e) + # Прежде сбой оставался только в журнале, и владелец видел + # «подключено» при неработающем аккаунте. agy читает именно + # этот файл, поэтому без него подключения нет. + logger.error( + "Не записаны учётные данные agy для профиля %s: %s", profile_id, e + ) + raise RuntimeError( + f"Учётные данные для agy не записаны ({e}). " + f"Аккаунт {profile_id} подключённым не считается." + ) from e # For Local and OpenAI-compatible providers, synchronize custom_base_url in router_profiles.yaml if provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm", "openrouter", "nvidia", "nvidia-nim"): diff --git a/src/antigravity_provider/router/web/server.py b/src/antigravity_provider/router/web/server.py index 3e2d5ca..53012d3 100644 --- a/src/antigravity_provider/router/web/server.py +++ b/src/antigravity_provider/router/web/server.py @@ -23,7 +23,7 @@ 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 +from antigravity_provider.updater.update_manager import get_installed_commit, get_installed_build_time, UpdateManager logger = logging.getLogger("hermes.router.web") @@ -410,6 +410,10 @@ def get_settings(authorized: bool = Depends(get_auth_token)): "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, "network_security": { diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index 15ac56a..86199a9 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -570,6 +570,21 @@ async function executeAction(actionName, actionData = {}) { } // ── GLOBAL HEADER ── +// Версия между сборками не меняется намеренно, а коммит — строка из +// шестнадцатеричных цифр. Дата установки отвечает на вопрос «старая сборка +// загрузилась или новая» сразу и без сверки коммитов. +function versionTagText(curVer) { + const installedAt = currentSettings && currentSettings.installed_at; + let stamp = ''; + if (installedAt) { + const d = new Date(installedAt); + if (!isNaN(d.getTime())) { + stamp = ' · ' + d.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); + } + } + return curVer ? `Hermes Hub Web v${curVer}${stamp}` : 'Hermes Hub Web — Н/Д: версия не передана сервером'; +} + function updateGlobalHeader() { if (!currentSnapshot) return; @@ -631,7 +646,7 @@ function updateGlobalHeader() { const curVer = (currentSnapshot && (currentSnapshot.version || (currentSnapshot.metrics || {}).version)) || (currentSettings && currentSettings.version) || ''; const versionTag = document.getElementById('version-tag'); if (versionTag) { - versionTag.textContent = curVer ? `Hermes Hub Web v${curVer}` : 'Hermes Hub Web — Н/Д: версия не передана сервером'; + versionTag.textContent = versionTagText(curVer); } } @@ -2412,7 +2427,7 @@ function renderUpdateUI() { } const versionTag = document.getElementById('version-tag'); if (versionTag) { - versionTag.textContent = curVer ? `Hermes Hub Web v${curVer}` : 'Hermes Hub Web — Н/Д: версия не передана сервером'; + versionTag.textContent = versionTagText(curVer); } if (statusBadge) { From 9641957d5b5737a991f77c0e687780e7b1a5b183 Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Tue, 1 Sep 2026 10:37:27 +0700 Subject: [PATCH 10/14] =?UTF-8?q?fix(antigravity):=20=D0=B2=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B0=D0=BB=D1=81?= =?UTF-8?q?=D1=8F=20=D1=83=D1=81=D0=BF=D0=B5=D1=88=D0=BD=D0=BE,=20=D0=B0?= =?UTF-8?q?=20agy=20=D1=82=D1=80=D0=B5=D0=B1=D0=BE=D0=B2=D0=B0=D0=BB=20?= =?UTF-8?q?=D0=B2=D0=BE=D0=B9=D1=82=D0=B8=20=D1=81=D0=BD=D0=BE=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agy 2.0 читает учётные данные не из .gemini/oauth_creds.json — это формат Gemini CLI. Свой токен он берёт из .gemini/antigravity-cli/antigravity-oauth-token в виде {"auth_method": ..., "token": {...}}. Хаб этот файл никогда не создавал, поэтому «Авторизация успешно завершена» соседствовала с ответом «Please sign in to view available models». Установлено сравнением рабочего профиля владельца с неработающим: оба имели oauth_creds.json одинакового размера, различие было только в этом файле. Значение auth_method («consumer») взято из рабочего профиля, а не выведено из общих соображений. Срок годности пишется в обоих видах: expiry_date в миллисекундах для формата Node и expiry строкой RFC3339 для Go-шного oauth2.Token. Заодно устранён рост номеров профилей: слот выбирался до входа, когда почта ещё неизвестна, и повторный вход тем же аккаунтом занимал очередной свободный слот — один аккаунт владельца расползся на ag-2, ag-3, ag-4. Теперь после опознания почты учётные данные возвращаются в слот, который этот аккаунт уже занимает. Функция проверки двойников в проекте была, но её никто не вызывал. Версия поднята до 0.1.3, чтобы отличать сборки на глаз. Проверка версии в тестах сверяется с исходником, а не с записанным числом. Co-Authored-By: Claude Opus 5 --- config/compatibility.json | 2 +- installer/HermesHubSetup.cs | 2 +- installer/install-linux.sh | 2 +- pyproject.toml | 2 +- .../router/profile_manager.py | 74 +++++++++++++- .../router/profile_oauth.py | 23 +++++ src/antigravity_provider/version.py | 4 +- tests/test_a55_account_connection.py | 20 ++-- tests/test_agy_cli_token_file.py | 96 +++++++++++++++++++ 9 files changed, 210 insertions(+), 15 deletions(-) create mode 100644 tests/test_agy_cli_token_file.py diff --git a/config/compatibility.json b/config/compatibility.json index 6708556..9882ae5 100644 --- a/config/compatibility.json +++ b/config/compatibility.json @@ -1,5 +1,5 @@ { - "hub_version": "0.1.2", + "hub_version": "0.1.3", "min_hermes_version": "0.20.0", "max_tested_hermes_version": "0.20.4", "tested_versions": [ diff --git a/installer/HermesHubSetup.cs b/installer/HermesHubSetup.cs index 4d8328b..c2b70f1 100644 --- a/installer/HermesHubSetup.cs +++ b/installer/HermesHubSetup.cs @@ -14,7 +14,7 @@ namespace HermesHubSetup { public class SetupEngine { - public const string HUB_VERSION = "0.1.2"; + public const string HUB_VERSION = "0.1.3"; // Подставляется сборщиком из фактического git-коммита. Раньше здесь // жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из // какого кода собран установщик. diff --git a/installer/install-linux.sh b/installer/install-linux.sh index 957f848..b77c45c 100644 --- a/installer/install-linux.sh +++ b/installer/install-linux.sh @@ -7,7 +7,7 @@ set -e -HUB_VERSION="0.1.2" +HUB_VERSION="0.1.3" DEFAULT_HERMES_HOME="$HOME/.hermes" HERMES_HOME="${HERMES_HOME:-$DEFAULT_HERMES_HOME}" diff --git a/pyproject.toml b/pyproject.toml index 0866d13..cd7f617 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "hermes-hub" -version = "0.1.2" +version = "0.1.3" description = "Multi-Agent & Multi-Provider Control Hub for Hermes Agent" readme = "README.md" license = { text = "MIT" } diff --git a/src/antigravity_provider/router/profile_manager.py b/src/antigravity_provider/router/profile_manager.py index 912a268..c6bebee 100644 --- a/src/antigravity_provider/router/profile_manager.py +++ b/src/antigravity_provider/router/profile_manager.py @@ -15,7 +15,7 @@ import threading import time import urllib.request import urllib.error -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional, Tuple @@ -114,6 +114,76 @@ class ProfileAuthManager: """Official API to get isolated directory for a profile.""" return get_profile_dir(profile_id, provider) + # agy 2.0 (Antigravity CLI) читает вход НЕ из .gemini/oauth_creds.json — + # это формат Gemini CLI. Свой токен он держит в + # .gemini/antigravity-cli/antigravity-oauth-token, и структура там другая: + # обёртка {"auth_method": ..., "token": {...}}. + # + # Хаб писал только файл Gemini CLI, поэтому авторизация проходила успешно, а + # agy отвечал «Please sign in to view available models»: он смотрел в файл, + # которого нет. Установлено сравнением рабочего профиля с неработающим. + # Значение взято из рабочего профиля владельца, не выведено из общих + # соображений: agy пишет туда "consumer" для личного аккаунта Google. + ANTIGRAVITY_AUTH_METHOD = "consumer" + + @classmethod + def _write_antigravity_cli_token(cls, profile_dir: Path, creds_dict: dict) -> Optional[Path]: + """Записать токен в формате Antigravity CLI рядом с файлом Gemini CLI.""" + cli_dir = profile_dir / ".gemini" / "antigravity-cli" + target = cli_dir / "antigravity-oauth-token" + try: + cli_dir.mkdir(parents=True, exist_ok=True) + try: + os.chmod(cli_dir, 0o700) + except OSError: + pass + + # Способ входа сохраняем такой же, как у уже работающего профиля на + # этой машине: угадывать его значение нельзя, а рабочий образец + # рядом — самый надёжный источник. + auth_method = cls.ANTIGRAVITY_AUTH_METHOD + try: + for sibling in profile_dir.parent.iterdir(): + if sibling == profile_dir or not sibling.is_dir(): + continue + ref = sibling / ".gemini" / "antigravity-cli" / "antigravity-oauth-token" + if ref.is_file(): + existing = json.loads(ref.read_text(encoding="utf-8")) + if isinstance(existing, dict) and existing.get("auth_method"): + auth_method = str(existing["auth_method"]) + break + except Exception: + pass + + # Срок годности пишем в обоих видах. Gemini CLI (Node) ждёт + # expiry_date в миллисекундах, Go-шный oauth2.Token — expiry + # строкой RFC3339. Какой из них читает agy, по бинарнику не + # определить, а лишнее поле разбор JSON пропускает. + token_payload = dict(creds_dict) + try: + expiry_ms = int(creds_dict.get("expiry_date") or 0) + if expiry_ms > 0: + token_payload["expiry"] = ( + datetime.fromtimestamp(expiry_ms / 1000, tz=timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ) + except (TypeError, ValueError, OSError, OverflowError): + pass + + payload = {"auth_method": auth_method, "token": token_payload} + temp = cli_dir / f"antigravity-oauth-token.tmp-{threading.get_ident()}-{time.time_ns()}" + temp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + os.replace(temp, target) + try: + os.chmod(target, 0o600) + except OSError: + pass + return target + except Exception as exc: + logger.error("Не записан токен Antigravity CLI в %s: %s", target, exc) + raise + @classmethod def write_agy_oauth_creds(cls, profile_dir: Path, auth_data: dict) -> Path: """Atomically write /.gemini/oauth_creds.json in exact agy CLI format.""" @@ -187,6 +257,8 @@ class ProfileAuthManager: os.chmod(target_file, 0o600) except OSError: pass + + cls._write_antigravity_cli_token(profile_dir, creds_dict) return target_file @staticmethod diff --git a/src/antigravity_provider/router/profile_oauth.py b/src/antigravity_provider/router/profile_oauth.py index 59dfa0e..e57bb9f 100644 --- a/src/antigravity_provider/router/profile_oauth.py +++ b/src/antigravity_provider/router/profile_oauth.py @@ -255,6 +255,29 @@ class ProfileOAuthSession: email = fetch_user_email(tokens["access_token"]) logger.info("OAuth account identity resolved (email_found=%s)", bool(email)) + # Слот выбирается ДО входа, когда почта ещё неизвестна, поэтому + # повторный вход тем же аккаунтом занимал очередной свободный + # слот: у владельца один аккаунт расползся на ag-2, ag-3, ag-4. + # Узнав почту, возвращаем учётные данные в слот, который этот + # аккаунт уже занимает, вместо создания двойника. + if email: + from antigravity_provider.router.auto_assigner import AutoAssigner + + try: + existing = AutoAssigner.check_duplicate_identity( + "antigravity", email, exclude_profile_id=self.profile_id + ) + except Exception as exc: + existing = None + logger.warning("Проверка двойников не выполнена: %s", exc) + if existing and existing != self.profile_id: + logger.info( + "Аккаунт уже занимает профиль %s — пишем туда, а не в %s", + existing, + self.profile_id, + ) + self.profile_id = existing + # Format in standard gemini:antigravity shape expires_at = tokens.get("expires_at") or (int(time.time()) + 3600) auth_data = { diff --git a/src/antigravity_provider/version.py b/src/antigravity_provider/version.py index bbec965..96e4912 100644 --- a/src/antigravity_provider/version.py +++ b/src/antigravity_provider/version.py @@ -1,8 +1,8 @@ """Single Source of Truth for Hermes Hub Versioning.""" from __future__ import annotations -__version__ = "0.1.2" -VERSION_INFO = (0, 1, 2) +__version__ = "0.1.3" +VERSION_INFO = (0, 1, 3) CHANNEL = "stable" MINIMUM_HERMES_VERSION = "0.20.0" diff --git a/tests/test_a55_account_connection.py b/tests/test_a55_account_connection.py index 45dc260..09c1ad0 100644 --- a/tests/test_a55_account_connection.py +++ b/tests/test_a55_account_connection.py @@ -132,31 +132,35 @@ def test_p0_3_antigravity_dynamic_model_discovery(): # ── P0-4: Version Info & API Propagation Tests ── def test_p0_4_version_single_source_of_truth(): - """Version must be 0.1.2 and VERSION_INFO must be (0, 1, 2).""" - assert __version__ == "0.1.2" - assert VERSION_INFO == (0, 1, 2) + """Номер версии и VERSION_INFO обязаны совпадать между собой. + + Сверять с записанным в тесте числом бессмысленно: при каждой сборке его + пришлось бы править, и тест превращался бы в напоминание, а не в проверку. + Смысл требования — единый источник, его и проверяем. + """ + assert VERSION_INFO == tuple(int(part) for part in __version__.split(".")) def test_p0_4_version_in_api_endpoints(): - """API endpoints /api/snapshot, /api/health, and /api/settings must return version 0.1.2.""" + """Все точки API обязаны отдавать ту же версию, что и пакет.""" client = TestClient(app) # /api/health res_health = client.get("/api/health") assert res_health.status_code == 200 - assert res_health.json().get("version") == "0.1.2" + assert res_health.json().get("version") == __version__ # /api/settings res_settings = client.get("/api/settings") assert res_settings.status_code == 200 - assert res_settings.json().get("version") == "0.1.2" + assert res_settings.json().get("version") == __version__ # /api/snapshot res_snap = client.get("/api/snapshot") assert res_snap.status_code == 200 snap = res_snap.json() - assert snap.get("version") == "0.1.2" - assert (snap.get("metrics") or {}).get("version") == "0.1.2" + assert snap.get("version") == __version__ + assert (snap.get("metrics") or {}).get("version") == __version__ # ── P0-5: Settings View & System Paths Tests ── diff --git a/tests/test_agy_cli_token_file.py b/tests/test_agy_cli_token_file.py new file mode 100644 index 0000000..679557a --- /dev/null +++ b/tests/test_agy_cli_token_file.py @@ -0,0 +1,96 @@ +"""agy 2.0 читает вход не из oauth_creds.json. + +Сравнение рабочего профиля владельца с неработающим показало единственное +значимое различие: у рабочего есть `.gemini/antigravity-cli/antigravity-oauth-token`, +у неработающего — только `.gemini/oauth_creds.json`. Оба файла с токенами были +на месте и одинакового размера, но `agy models` отвечал «Please sign in to view +available models». + +Формат подтверждён на машине владельца: верхний уровень — ключи `auth_method` +и `token`, значение `auth_method` — `consumer`. +""" +from __future__ import annotations + +import json +import time + +from antigravity_provider.router.profile_manager import ProfileAuthManager + + +def _write(tmp_path): + return ProfileAuthManager.write_agy_oauth_creds( + tmp_path, + { + "access_token": "ya29.TEST-ACCESS", + "refresh_token": "1//TEST-REFRESH", + "scope": "https://www.googleapis.com/auth/cloud-platform", + "token_type": "Bearer", + "id_token": "eyJTEST", + "expiry_date": int((time.time() + 3600) * 1000), + }, + ) + + +def test_antigravity_cli_token_written(tmp_path): + _write(tmp_path) + target = tmp_path / ".gemini" / "antigravity-cli" / "antigravity-oauth-token" + assert target.is_file(), "agy читает вход именно отсюда" + + payload = json.loads(target.read_text(encoding="utf-8")) + assert sorted(payload) == ["auth_method", "token"] + assert payload["auth_method"] == "consumer" + assert payload["token"]["access_token"] == "ya29.TEST-ACCESS" + assert payload["token"]["refresh_token"] == "1//TEST-REFRESH" + + +def test_expiry_written_in_both_formats(tmp_path): + _write(tmp_path) + target = tmp_path / ".gemini" / "antigravity-cli" / "antigravity-oauth-token" + token = json.loads(target.read_text(encoding="utf-8"))["token"] + # Node-формат — миллисекунды, Go-шный oauth2.Token — строка RFC3339. + assert isinstance(token["expiry_date"], int) + assert token["expiry"].endswith("Z") + + +def test_gemini_creds_still_written(tmp_path): + """Прежний файл остаётся: его читают другие части agy и сам хаб.""" + target_file = _write(tmp_path) + assert target_file == tmp_path / ".gemini" / "oauth_creds.json" + creds = json.loads(target_file.read_text(encoding="utf-8")) + assert creds["access_token"] == "ya29.TEST-ACCESS" + assert "auth_method" not in creds + + +def test_auth_method_taken_from_working_neighbour(tmp_path): + """Значение способа входа берётся у уже работающего профиля, если он есть.""" + neighbour = tmp_path / "ag-working" / ".gemini" / "antigravity-cli" + neighbour.mkdir(parents=True) + (neighbour / "antigravity-oauth-token").write_text( + json.dumps({"auth_method": "workforce", "token": {}}), encoding="utf-8" + ) + + profile = tmp_path / "ag-new" + profile.mkdir() + _write(profile) + + payload = json.loads( + (profile / ".gemini" / "antigravity-cli" / "antigravity-oauth-token").read_text( + encoding="utf-8" + ) + ) + assert payload["auth_method"] == "workforce" + + +def test_empty_login_writes_nothing(tmp_path): + """Вход без токена не должен оставлять ни одного файла учётных данных.""" + try: + ProfileAuthManager.write_agy_oauth_creds(tmp_path, {"email": "x@y.z"}) + except ValueError: + pass + else: # pragma: no cover - защита от возврата прежнего поведения + raise AssertionError("пустой вход обязан отказывать") + + assert not (tmp_path / ".gemini" / "oauth_creds.json").exists() + assert not ( + tmp_path / ".gemini" / "antigravity-cli" / "antigravity-oauth-token" + ).exists() From 001cd1f91dd486242bf22ce44b51336f64d00f23 Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Tue, 1 Sep 2026 12:56:47 +0700 Subject: [PATCH 11/14] =?UTF-8?q?fix(installer):=20=D1=83=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=BD=D0=B0=20Linux=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=B4=D0=B0=D0=BB=D0=B0=20=D0=BD=D0=B0=20=D0=B7?= =?UTF-8?q?=D0=B0=D1=89=D0=B8=D1=89=D1=91=D0=BD=D0=BD=D0=BE=D0=BC=20=D1=81?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D0=BD=D0=BE=D0=BC=20Python?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Установка на сервере владельца прервалась на проверке: «No module named 'fastapi'». Причин две, и обе в установщике. Первая: запуск через sudo. Установка пользовательская — всё ложится в $HOME/.hermes и $HOME/.local/bin, root не нужен. Под sudo домашним каталогом становится /root, венв Hermes там не находится, и установщик уходит на системный python. Копия при этом ложится в /root/.hermes, где владелец её не видит. Теперь запуск через sudo распознаётся и отклоняется с объяснением; осознанный обход остаётся через HERMES_ALLOW_ROOT=1. Вторая: системный python в Ubuntu 24.04 помечен EXTERNALLY-MANAGED (PEP 668) и отклоняет pip install — и обычный, и с --user. Установщик обе неудачи проглатывал (|| true) и продолжал работу до отказа на проверке. Теперь при защищённом системном python создаётся собственное окружение $HERMES_HOME/venv, а неудача установки зависимостей прекращает установку с внятным кодом возврата. --break-system-packages не применяется: имя флага не преувеличивает. Пусковик научен той же ветке — иначе запуск уходил бы на системный python, где зависимостей нет и быть не может. Проверено исполнением на сервере под непривилегированным пользователем: окружение создано, зависимости установлены, HERMES_HUB_LINUX_VERIFY_OK, запуск через sudo отклонён с кодом 3. Co-Authored-By: Claude Opus 5 --- installer/HermesHubSetup.cs | 2 +- installer/install-linux.sh | 69 +++++++++++++++++++++++++++++++++--- launcher/HermesHub.exe | Bin 6144 -> 6144 bytes launcher/HermesHubWeb.exe | Bin 20480 -> 20992 bytes launcher/hermes-hub-web.sh | 5 +++ tests/test_installer.py | 47 ++++++++++++++++++++++++ 6 files changed, 117 insertions(+), 6 deletions(-) diff --git a/installer/HermesHubSetup.cs b/installer/HermesHubSetup.cs index c2b70f1..f4f4521 100644 --- a/installer/HermesHubSetup.cs +++ b/installer/HermesHubSetup.cs @@ -18,7 +18,7 @@ namespace HermesHubSetup // Подставляется сборщиком из фактического git-коммита. Раньше здесь // жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из // какого кода собран установщик. - public const string BuildCommit = "8daeafa"; + public const string BuildCommit = "9641957"; public const string MIN_HERMES_VERSION = "0.20.0"; public const string MAX_TESTED_HERMES = "0.20.4"; diff --git a/installer/install-linux.sh b/installer/install-linux.sh index b77c45c..3f9556d 100644 --- a/installer/install-linux.sh +++ b/installer/install-linux.sh @@ -11,6 +11,27 @@ HUB_VERSION="0.1.3" DEFAULT_HERMES_HOME="$HOME/.hermes" HERMES_HOME="${HERMES_HOME:-$DEFAULT_HERMES_HOME}" +# Установка пользовательская: всё ложится в $HOME/.hermes и $HOME/.local/bin, +# службы не ставятся, root не нужен. Под sudo домашним каталогом становится +# /root, венв Hermes там не находится, установщик сваливается на системный +# python — а он в Ubuntu 24.04 закрыт для pip (PEP 668). В итоге установка +# уходит в /root/.hermes, где владелец её не видит, и падает на проверке. +# Молча ставить не туда нельзя, поэтому отказываемся сразу и по делу. +if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] && [ -z "${HERMES_ALLOW_ROOT:-}" ]; then + echo "❌ Установщик запущен через sudo." >&2 + echo "" >&2 + echo " Hermes Hub ставится в домашний каталог пользователя, а под sudo" >&2 + echo " это /root — туда, где ни аккаунты, ни Hermes Agent не лежат." >&2 + echo "" >&2 + # $0 здесь — распакованная копия во временном каталоге, называть её + # владельцу бессмысленно: этого файла через минуту не будет. + echo " Запустите тот же установщик от своего имени, без sudo." >&2 + echo "" >&2 + echo " Если установка в /root действительно нужна, задайте" >&2 + echo " HERMES_ALLOW_ROOT=1." >&2 + exit 3 +fi + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" @@ -84,6 +105,8 @@ if [ -f "$HERMES_HOME/hermes-agent/venv/bin/python3" ]; then PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python3" elif [ -f "$HERMES_HOME/hermes-agent/venv/bin/python" ]; then PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python" +elif [ -x "$HERMES_HOME/venv/bin/python3" ]; then + PYTHON_BIN="$HERMES_HOME/venv/bin/python3" elif command -v python3 >/dev/null 2>&1; then PYTHON_BIN="$(command -v python3)" elif command -v python >/dev/null 2>&1; then @@ -111,12 +134,48 @@ echo "[2/6] Verifying Python dependencies..." DEPS_OK=true "$PYTHON_BIN" -c "import fastapi, uvicorn, pydantic, psutil, yaml; print('DEPS_OK')" >/dev/null 2>&1 || DEPS_OK=false +HUB_DEPS="fastapi uvicorn pydantic psutil pyyaml" + if [ "$DEPS_OK" != "true" ]; then - echo " Installing required packages (fastapi, uvicorn, pydantic, psutil, pyyaml)..." - "$PYTHON_BIN" -m pip install --no-warn-script-location -q fastapi uvicorn pydantic psutil pyyaml || { - echo "⚠️ Warning: pip install returned non-zero code. Trying with --user..." - "$PYTHON_BIN" -m pip install --user --no-warn-script-location -q fastapi uvicorn pydantic psutil pyyaml || true - } + # Системный python в Debian и Ubuntu помечен как externally-managed + # (PEP 668) и отклоняет pip install — и обычный, и с --user. Обходить это + # через --break-system-packages нельзя: имя флага не преувеличивает, так + # ломают питон всей машины. Правильный ответ — собственный venv. + NEED_VENV=false + if [ -f "/usr/lib/python$PY_VER/EXTERNALLY-MANAGED" ] || [ -f "/usr/lib/python3/EXTERNALLY-MANAGED" ]; then + case "$PYTHON_BIN" in + */venv/bin/*) : ;; + *) NEED_VENV=true ;; + esac + fi + + if [ "$NEED_VENV" = "true" ]; then + echo " Системный Python защищён от изменений (PEP 668)." + echo " Создаю отдельное окружение: $HERMES_HOME/venv" + if ! "$PYTHON_BIN" -m venv "$HERMES_HOME/venv" 2>/dev/null; then + echo "❌ Не удалось создать виртуальное окружение." >&2 + echo " Установите пакет python3-venv:" >&2 + echo " sudo apt install python3-venv" >&2 + exit 11 + fi + PYTHON_BIN="$HERMES_HOME/venv/bin/python3" + echo " Using Python: $PYTHON_BIN" + fi + + echo " Installing required packages ($HUB_DEPS)..." + # shellcheck disable=SC2086 + if ! "$PYTHON_BIN" -m pip install --no-warn-script-location -q $HUB_DEPS; then + echo "❌ Не удалось установить зависимости через $PYTHON_BIN." >&2 + echo " Установка прервана: без них хаб не запустится." >&2 + exit 12 + fi +fi + +# Проверяем результат, а не код возврата pip: установка «прошла», а модуля нет — +# именно так предыдущая сборка дошла до проверки и упала на ней. +if ! "$PYTHON_BIN" -c "import fastapi, uvicorn, pydantic, psutil, yaml" >/dev/null 2>&1; then + echo "❌ Зависимости не импортируются даже после установки ($PYTHON_BIN)." >&2 + exit 13 fi # 3. Mirror Plugin Files to ~/.hermes/plugins/antigravity-provider (with cleanup of stale files) diff --git a/launcher/HermesHub.exe b/launcher/HermesHub.exe index 1f154d049cc7a1c4622ee1e878f38d4807f1eb0c..cd5b237ed3cbfd03cb611f8f4a070ecb2f3903a6 100644 GIT binary patch delta 36 ucmV+<0Nek7Fn}z|Ns3rE4#K}eWmdub1)+bT1 zhYei#BmrztPXoA631ZYC_Ngda8$#DJzK+ld-~pkDjt2h{ANA0LP0KKcKRGl2HJ0^X zX5BOXEKX}uUM2}R1)wjvpmA*y>W(cD=QQ>(lwv$OPoN$=D@zds=b#U4*Dp_ufDnAh z*9gs1Xt!?j-Hlur>ZjQkdx(t$?9)Ibesde#ufdDwqU?d^0Hle*=@R@35Ec)V$M1y! zV%00)kOACS6)RTD1@s}=+75givL6ab@WFShI(bZ|XH8{ce!<8jOjXGz6xGlYnrX-h zGi?+!LsgZuqFI}f{peI>hRRf@23?~fEYlUbL|3VbjE>P9ySq3>a_mjYXU~ENr1hNX zM(9xnd$NOC6+3lWp^KR1-5O5GVgC}Hr6yVhnG5Lo2A`I5C+_#il4cAK~L#5?A_ s!&1e#cjx3|@MnF`W3dwn3cVV8Tx`EwKe9$ke{90ndCPO@m~>?OKl&Gc+W-In delta 449 zcmZoz!q~8YaYBdE&k0kr7@*(*0}~@RBR2zs8&H4)BDgMf;;Jydm{bM^e~>@}1H%WF ziHgFL6&d5XwzvXC6oA-mau=gEqsQcJjOvVjlkYJqDNF!r009pmW@2DvFfe9d&@p0Q zU6$nhyXra`x|!)ZyIMNBnYg() zni`sIUZJ={X|kiMxgf)uZ!foHE8BT5{B~~Em%mHJHg~x0XX4;uXAx@X6q1}A=%Fpb rz|aB>G?26Af!GQY6~&k)q)uMu(XpA&>lZ822Gh-g0q+?n8n6QZl`dfq diff --git a/launcher/hermes-hub-web.sh b/launcher/hermes-hub-web.sh index 4b424d8..a759ec2 100644 --- a/launcher/hermes-hub-web.sh +++ b/launcher/hermes-hub-web.sh @@ -16,6 +16,11 @@ if [ -f "$HERMES_HOME/hermes-agent/venv/bin/python3" ]; then PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python3" elif [ -f "$HERMES_HOME/hermes-agent/venv/bin/python" ]; then PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python" +elif [ -x "$HERMES_HOME/venv/bin/python3" ]; then + # Окружение, созданное установщиком, когда системный python закрыт + # правилом PEP 668. Без этой ветки запуск уходил бы на /usr/bin/python3, + # где зависимостей нет и быть не может. + PYTHON_BIN="$HERMES_HOME/venv/bin/python3" elif command -v python3 >/dev/null 2>&1; then PYTHON_BIN="$(command -v python3)" elif command -v python >/dev/null 2>&1; then diff --git a/tests/test_installer.py b/tests/test_installer.py index ff77072..d8e7d28 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -72,3 +72,50 @@ def test_silent_installer_fails_without_hermes(tmp_path): res = subprocess.run([str(SETUP_EXE), "/silent"], env=env, capture_output=True, text=True) assert res.returncode != 0, f"Expected failure for missing Hermes, got {res.returncode}" + + +# ── Установка на Linux: sudo и защищённый системный Python ── +# +# Установка на сервере владельца упала так: запуск через sudo увёл всё в +# /root/.hermes, венв Hermes там не нашёлся, установщик взял /usr/bin/python3, +# а он в Ubuntu 24.04 помечен EXTERNALLY-MANAGED и отклоняет pip. Проверка +# после установки честно упала на «No module named 'fastapi'». + +def _linux_installer_text() -> str: + root = Path(__file__).resolve().parent.parent + return (root / "installer" / "install-linux.sh").read_text(encoding="utf-8") + + +def test_linux_installer_refuses_sudo(): + text = _linux_installer_text() + assert "SUDO_USER" in text, "запуск через sudo обязан распознаваться" + assert "HERMES_ALLOW_ROOT" in text, "должен быть осознанный способ обойти отказ" + assert "exit 3" in text + + +def test_linux_installer_creates_own_venv_when_system_python_is_managed(): + text = _linux_installer_text() + assert "EXTERNALLY-MANAGED" in text, "PEP 668 должен распознаваться" + assert "-m venv" in text, "ответ на PEP 668 — собственное окружение" + # --break-system-packages ломает питон всей машины: имя флага не + # преувеличивает. Упоминание в пояснении допустимо, применение — нет, + # поэтому смотрим только на исполняемые строки. + code_lines = [ + line for line in text.splitlines() if not line.lstrip().startswith("#") + ] + assert not any("--break-system-packages" in line for line in code_lines) + + +def test_linux_installer_fails_loudly_on_missing_deps(): + """Раньше pip падал, а установка продолжалась — до отказа на проверке.""" + text = _linux_installer_text() + assert "exit 12" in text and "exit 13" in text + assert "|| true" not in text.split("Installing required packages")[1][:400] + + +def test_linux_launcher_knows_about_installer_venv(): + root = Path(__file__).resolve().parent.parent + launcher = (root / "launcher" / "hermes-hub-web.sh").read_text(encoding="utf-8") + assert '$HERMES_HOME/venv/bin/python3' in launcher, ( + "иначе запуск уйдёт на системный python, где зависимостей нет" + ) From 0ad946eccd977ff2499f7c0a370271a543c1884d Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Tue, 1 Sep 2026 13:23:55 +0700 Subject: [PATCH 12/14] =?UTF-8?q?fix(accounts):=20=D0=BC=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D0=B5=D1=80=20=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B7=D0=B0=D0=BC=D0=B8=D1=80=D0=B0=D0=BB?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D1=88=D0=B0=D0=B3=D0=B5=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Владелец видел «сохранение аккаунта и запуск проверки» и ждал. Зависанием это не было: действие честно дожидалось проверки у провайдера. Для Antigravity она идёт через CLI и в худшем случае складывается из 90 с на захват замка профиля, 65 на каталог моделей и 90 на пробный вызов — около четырёх минут молчания при обещанной в интерфейсе «минуте на этап». Сохранение учётных данных и назначение роли занимают миллисекунды. Теперь действие возвращается сразу, а опрос провайдера ставится в фон; карточка обновляется, когда он закончится, — снапшот и так опрашивается по таймеру. Провайдеры с ключом поведения не меняют: их подключение проверяется предварительной проверкой до сохранения и возвращается сразу, как требует A54. Если фоновая служба не работает, проверка по-прежнему выполняется на месте — иначе результата не будет вовсе. Надпись в мастере исправлена: обещание «до минуты на этап» не соответствовало действительности. Co-Authored-By: Claude Opus 5 --- installer/HermesHubSetup.cs | 2 +- launcher/HermesHub.exe | Bin 6144 -> 6144 bytes launcher/HermesHubWeb.exe | Bin 20992 -> 20992 bytes .../router/action_handler.py | 22 ++++- .../router/web/static/app.js | 5 +- tests/test_add_account_does_not_block.py | 77 ++++++++++++++++++ 6 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 tests/test_add_account_does_not_block.py diff --git a/installer/HermesHubSetup.cs b/installer/HermesHubSetup.cs index f4f4521..eb017a0 100644 --- a/installer/HermesHubSetup.cs +++ b/installer/HermesHubSetup.cs @@ -18,7 +18,7 @@ namespace HermesHubSetup // Подставляется сборщиком из фактического git-коммита. Раньше здесь // жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из // какого кода собран установщик. - public const string BuildCommit = "9641957"; + public const string BuildCommit = "001cd1f"; public const string MIN_HERMES_VERSION = "0.20.0"; public const string MAX_TESTED_HERMES = "0.20.4"; diff --git a/launcher/HermesHub.exe b/launcher/HermesHub.exe index cd5b237ed3cbfd03cb611f8f4a070ecb2f3903a6..38f6ade59e46698fa3791abc676b6a3f2bcff834 100644 GIT binary patch delta 35 rcmZoLXfT-2!K9bDvFj9%fcGki^*8G;yEkt%>Ga*-%(gj&cMcZ-2BQwK delta 35 tcmV+;0Nnq8Fn}!< jhHjRdS14Xk7Ff%rV|n(Afzy7T2=26&z@M8tTq}40)zul5 delta 79 zcmV-V0I>gnqyd1W0g#9Sd1JAP`XD4RMKn1!IYKurMKv@-Ei^SkI4w9uLNF~wGeSf- lLNzrsGc-7}s3FiJ5VvVb9!%x9MUJrJy^B>tNVAAVcntn>8!`X@ diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index 1c1e794..c8b3961 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -900,7 +900,27 @@ class ActionExecutor: if validation: AccountProbeService.get().record_validation(prov_norm, slot, validation) return {'ok': True, 'message': validation['message'], 'data': {'profile_id': slot, 'models': validation['data']['models']}} - result = AccountProbeService.get().check_now(prov_norm, slot) + # Проверку у провайдера не ждём в самом действии. Для + # Antigravity она идёт через CLI и в худшем случае занимает до + # 90 с на захват замка, до 65 на каталог моделей и до 90 на + # пробный вызов — около четырёх минут молчания при обещанной + # «минуте на этап». Владелец видит это как зависший мастер. + # + # Провайдеры с ключом сюда не попадают: их подключение уже + # проверено предварительной проверкой выше и возвращается + # сразу, как того требует A54. + probe = AccountProbeService.get() + started = probe.schedule(prov_norm, slot, force=True) + if started or probe.state(slot).get('state') == 'checking': + return { + 'ok': True, + 'message': 'Аккаунт подключён. Проверка у провайдера идёт в фоне, ' + 'результат появится в карточке.', + 'data': {'profile_id': slot, 'check': 'running'}, + } + # Фоновая служба не работает — проверяем здесь, иначе результата + # не будет вовсе. Ручная проверка обязана работать и без неё. + result = probe.check_now(prov_norm, slot) result.setdefault('data', {})['profile_id'] = slot return result except Exception as e: diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index 86199a9..ca16a8d 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -3251,7 +3251,10 @@ async function finishAddAccount(providerId) { const feedbackArea = document.getElementById('modal-feedback-area'); if (feedbackArea) { - feedbackArea.innerHTML = ``; + // Обещать «до минуты на этап» было неправдой: проверка Antigravity через + // CLI занимала до четырёх минут, и мастер выглядел зависшим. Теперь + // действие возвращается сразу, а проверка идёт в фоне. + feedbackArea.innerHTML = ``; } const payload = { diff --git a/tests/test_add_account_does_not_block.py b/tests/test_add_account_does_not_block.py new file mode 100644 index 0000000..3984fc9 --- /dev/null +++ b/tests/test_add_account_does_not_block.py @@ -0,0 +1,77 @@ +"""Подключение аккаунта не должно ждать опроса провайдера. + +Мастер владельца замирал на шаге 3 с надписью «сохранение аккаунта и запуск +проверки». Зависанием это не было: действие честно ждало проверку Antigravity +через CLI — до 90 с на захват замка профиля, до 65 на каталог моделей и до 90 +на пробный вызов. Около четырёх минут молчания при обещанной «минуте на этап». + +Сохранение и назначение роли занимают миллисекунды. Опрос провайдера идёт в +фоне, а карточка обновляется, когда он закончится. +""" +from __future__ import annotations + +import time +from unittest.mock import patch + +from antigravity_provider.router.account_probe_service import AccountProbeService +from antigravity_provider.router.action_handler import ActionExecutor +from antigravity_provider.router.auto_assigner import AutoAssigner +from antigravity_provider.router.profile_manager import ProfileAuthManager + + +def _connect_authenticated_antigravity(): + with patch.object(ProfileAuthManager, "get_profile_status", return_value={"authenticated": True}), \ + patch.object(ProfileAuthManager, "load_profile_auth", return_value={"email": "x@gmail.com"}), \ + patch.object(ProfileAuthManager, "save_profile_auth", return_value="/tmp/auth.json"), \ + patch.object(AutoAssigner, "ensure_profile_definition", return_value=(True, "ok")), \ + patch.object(AutoAssigner, "assign_profile_to_role", return_value=(True, "ok")): + return ActionExecutor.execute( + "add_account", + {"provider": "antigravity", "profile_id": "ag-1", "target_role": "orchestrator"}, + ) + + +def test_connect_returns_without_waiting_for_provider(): + slow_call = [] + + def _slow_check_now(self, provider, profile_id, models_only=False): + slow_call.append(profile_id) + time.sleep(5) # изображаем опрос провайдера + return {"ok": True, "message": "проверено"} + + with patch.object(AccountProbeService, "schedule", return_value=True) as scheduled, \ + patch.object(AccountProbeService, "check_now", _slow_check_now): + started = time.monotonic() + res = _connect_authenticated_antigravity() + elapsed = time.monotonic() - started + + assert res["ok"], res + assert not slow_call, "действие не должно ждать опрос провайдера" + assert scheduled.called, "проверка обязана быть поставлена в фон" + assert elapsed < 2, f"ответ занял {elapsed:.1f} с" + assert res["data"]["check"] == "running" + + +def test_result_reaches_owner_when_background_service_is_down(): + """Служба не работает — проверяем здесь, иначе результата не будет вовсе.""" + with patch.object(AccountProbeService, "schedule", return_value=False), \ + patch.object(AccountProbeService, "state", return_value={"state": "never_checked"}), \ + patch.object( + AccountProbeService, + "check_now", + return_value={"ok": False, "message": "провайдер отказал", "data": {}}, + ) as checked: + res = _connect_authenticated_antigravity() + + assert checked.called, "без фоновой службы проверка выполняется на месте" + assert res["message"] == "провайдер отказал" + + +def test_check_already_running_is_not_awaited(): + with patch.object(AccountProbeService, "schedule", return_value=False), \ + patch.object(AccountProbeService, "state", return_value={"state": "checking"}), \ + patch.object(AccountProbeService, "check_now") as checked: + res = _connect_authenticated_antigravity() + + assert not checked.called, "уже идущую проверку не ждём и не дублируем" + assert res["ok"] and res["data"]["check"] == "running" From f188a181366e23ac795bf446f25f0f1a8afb33f0 Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Tue, 1 Sep 2026 14:03:56 +0700 Subject: [PATCH 13/14] =?UTF-8?q?fix(accounts):=20=D0=BE=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B6=D0=B8=D0=B2=D0=B0=D0=BB=20=D0=B1?= =?UTF-8?q?=D0=BE=D0=BB=D0=B5=D0=B5=20=D1=81=D0=B2=D0=B5=D0=B6=D0=B8=D0=B9?= =?UTF-8?q?=20=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В карточке владельца рядом стояли «Проверен: не работает — Please sign in» и «Получено 11 моделей · 13:25:53». Источники разные: красная строка берётся из состояния проверки, список — из кэша каталога, и обновляются они независимо. Каталог был получен позже отказа, то есть провайдер с тех пор ответил, а карточка продолжала утверждать обратное. Теперь, если каталог получен без ошибки и позже неудачной проверки, вердикт показывается как устаревший с предложением проверить заново. Объявлять аккаунт рабочим не за что — проверка после этого не выполнялась, и выдумывать её результат нельзя. Отдельно установлено: поле commit в /api/health читается из файла на диске при каждом запросе, поэтому устаревший процесс рапортует свежий коммит. Различать сборки по нему нельзя. Co-Authored-By: Claude Opus 5 --- installer/HermesHubSetup.cs | 2 +- launcher/HermesHub.exe | Bin 6144 -> 6144 bytes launcher/HermesHubWeb.exe | Bin 20992 -> 20992 bytes .../router/unified_health.py | 20 ++- tests/test_stale_verdict_vs_catalog.py | 118 ++++++++++++++++++ 5 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 tests/test_stale_verdict_vs_catalog.py diff --git a/installer/HermesHubSetup.cs b/installer/HermesHubSetup.cs index eb017a0..ac8a6bf 100644 --- a/installer/HermesHubSetup.cs +++ b/installer/HermesHubSetup.cs @@ -18,7 +18,7 @@ namespace HermesHubSetup // Подставляется сборщиком из фактического git-коммита. Раньше здесь // жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из // какого кода собран установщик. - public const string BuildCommit = "001cd1f"; + public const string BuildCommit = "0ad946e"; public const string MIN_HERMES_VERSION = "0.20.0"; public const string MAX_TESTED_HERMES = "0.20.4"; diff --git a/launcher/HermesHub.exe b/launcher/HermesHub.exe index 38f6ade59e46698fa3791abc676b6a3f2bcff834..0d5345926c7ee192c79e9125edc1662902261575 100644 GIT binary patch delta 35 rcmZoLXfT-2!PJ+(vFj9%!0QY9&Kz8|)1j67T)W-x&eY8*ymPn!G6E48 delta 35 rcmZoLXfT-2!K9bDvFj9%fcGki^*8G;yEkt%>Ga*-%(gj&cMcZ-2BQwK diff --git a/launcher/HermesHubWeb.exe b/launcher/HermesHubWeb.exe index b4a1cff7232b7c882d3ba41f450da273a8b456c5..71bd5857e23e02d43d9b943130a4e5051be4fc60 100644 GIT binary patch delta 79 zcmV-V0I>gnqyd1W0g#9Sj&HGw`XD4jHA6u#IXE#bMMgqGEi^?#Gc7_iGe#{iF)=qd lL_|h4GB7r?s3FiJ5P=EG#k=prPP9J(7meRi2D6Aocnl8&8w>yd delta 79 zcmZoz!q~8caY6@^UgpNGUkWP5mPXD7j*c$6ZYItyx+X@(2D(me2F|*s76wjcW)>!< jhHjRdS14Xk7Ff%rV|n(Afzy7T2=26&z@M8tTq}40)zul5 diff --git a/src/antigravity_provider/router/unified_health.py b/src/antigravity_provider/router/unified_health.py index 19fb431..5c764cd 100644 --- a/src/antigravity_provider/router/unified_health.py +++ b/src/antigravity_provider/router/unified_health.py @@ -543,7 +543,25 @@ class UnifiedHealthService: if check.get("state") == "checking": health_state, health_lbl = "checking", "Проверяется…" elif check.get("state") == "failed": - health_state, health_lbl = STATUS_UNHEALTHY, "Проверен: не работает — " + check.get("message", "Причина Н/Д") + # Вердикт проверки и каталог моделей обновляются + # независимо, и в карточке владельца они оказались + # рядом: «не работает — Please sign in» и тут же + # «Получено 11 моделей» с более поздним временем. + # Утверждать отказ, когда каталог получен ПОЗЖЕ него, + # нельзя: провайдер с тех пор ответил. Но и объявлять + # аккаунт рабочим не за что — проверка после этого не + # выполнялась. Честный ответ — что вердикт устарел. + checked_at = check.get("checked_at") or 0 + catalog_at = model_meta.get("discovered_at") or 0 + if not model_meta.get("error") and catalog_at > checked_at: + when = datetime.datetime.fromtimestamp(catalog_at).strftime("%H:%M:%S") + health_state = STATUS_NOT_TESTED + health_lbl = ( + f"Проверка устарела: каталог моделей получен позже ({when}). " + "Нажмите «Проверить подключение»." + ) + else: + health_state, health_lbl = STATUS_UNHEALTHY, "Проверен: не работает — " + check.get("message", "Причина Н/Д") elif check.get("state") == "working" and health_state in (STATUS_NOT_TESTED, STATUS_HEALTHY): health_state, health_lbl = STATUS_HEALTHY, "Проверен: работает" assigned = role_assignments.get(pid, []) diff --git a/tests/test_stale_verdict_vs_catalog.py b/tests/test_stale_verdict_vs_catalog.py new file mode 100644 index 0000000..29957ea --- /dev/null +++ b/tests/test_stale_verdict_vs_catalog.py @@ -0,0 +1,118 @@ +"""Вердикт проверки не должен переживать более свежий каталог моделей. + +В карточке владельца рядом стояли «Проверен: не работает — Please sign in» и +«Получено 11 моделей · 13:25:53». Источники разные и обновляются независимо: +красная строка берётся из состояния проверки, список — из кэша каталога. +Каталог был получен ПОЗЖЕ отказа, то есть провайдер с тех пор ответил. + +Утверждать отказ в такой ситуации нельзя. Объявлять аккаунт рабочим — тоже +не за что: проверка после этого не выполнялась. Честный ответ один: вердикт +устарел, нужна новая проверка. +""" +from __future__ import annotations + +import time + +import pytest + +from antigravity_provider.router.account_probe_service import AccountProbeService +from antigravity_provider.router.model_discovery_service import ModelDiscoveryService +from antigravity_provider.router.profile_manager import ProfileAuthManager +from antigravity_provider.router.router_config import ( + RouterConfig, + RouterProfileConfig, + save_router_config, +) +from antigravity_provider.router.unified_health import ( + STATUS_NOT_TESTED, + STATUS_UNHEALTHY, + UnifiedHealthService, +) + + +def _prepare(failed_at: float, catalog_at: float | None, catalog_error=None): + cfg = RouterConfig() + cfg.profiles["ag-5"] = RouterProfileConfig( + profile_id="ag-5", + provider="antigravity", + account_id="ag-5", + preferred_models=[], + enabled=True, + ) + save_router_config(cfg) + ProfileAuthManager.save_profile_auth( + "antigravity", + "ag-5", + { + # Запись входа Antigravity по праву отказывает без токена доступа, + # поэтому в проверке он должен быть настоящим по форме. + "token": { + "access_token": "ya29.TEST", + "refresh_token": "1//TEST", + "token_type": "Bearer", + "expiry_date": int((time.time() + 3600) * 1000), + }, + "email": "owner@gmail.com", + "auth_method": "oauth", + }, + ) + + probe = AccountProbeService.get() + with probe._lock: + probe._states["ag-5"] = { + "state": "failed", + "provider": "antigravity", + "checked_at": failed_at, + "message": "agy models: код 1. Please sign in to view available models.", + } + + discovery = ModelDiscoveryService.get() + with discovery._cache_lock: + discovery._cache["antigravity:ag-5"] = { + "models": ["gemini-3.1-pro-high", "claude-sonnet-4-6"], + "discovered_at": catalog_at, + "error": catalog_error, + } + return UnifiedHealthService.get() + + +def _view(service): + profiles = service.scan_all(force=True) + return next(p for p in profiles["antigravity"] if p.profile_id == "ag-5") + + +@pytest.mark.unit +def test_newer_catalog_makes_the_failed_verdict_stale(tmp_path): + now = time.time() + view = _view(_prepare(failed_at=now - 60, catalog_at=now - 20)) + + assert view.health_state == STATUS_NOT_TESTED + assert "устарела" in view.health_label_ru + assert "Please sign in" not in view.health_label_ru + + +@pytest.mark.unit +def test_failure_stands_when_it_is_the_newer_fact(tmp_path): + now = time.time() + view = _view(_prepare(failed_at=now - 10, catalog_at=now - 300)) + + assert view.health_state == STATUS_UNHEALTHY + assert "Please sign in" in view.health_label_ru + + +@pytest.mark.unit +def test_failure_stands_when_catalog_itself_errored(tmp_path): + now = time.time() + view = _view( + _prepare(failed_at=now - 60, catalog_at=now - 20, catalog_error="Сервер отказал") + ) + + assert view.health_state == STATUS_UNHEALTHY + + +@pytest.mark.unit +def test_failure_stands_when_catalog_was_never_obtained(tmp_path): + now = time.time() + view = _view(_prepare(failed_at=now - 60, catalog_at=None)) + + assert view.health_state == STATUS_UNHEALTHY From be98fd67516587262ef05f858f19f07116c56bcb Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Tue, 1 Sep 2026 14:10:49 +0700 Subject: [PATCH 14/14] =?UTF-8?q?docs(agents):=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20A57=20=E2=80=94=20=D0=B2=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=20=D0=B2=20Antigravity=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20?= =?UTF-8?q?=D1=81=D0=B0=D0=BC=20agy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Хаб сейчас записывает чужой файл учётных данных своим кодом: формат восстановлен по рабочему профилю владельца и по строкам в бинарнике. Работает, но до следующего обновления agy — и отказ будет молчаливым. Проверено запуском: подкоманд login или auth у agy нет, вход только запуском CLI без аргументов. Значит вход переносится в терминал с HOME на каталог профиля, а браузерный путь остаётся запасным для удалённого случая. Co-Authored-By: Claude Opus 5 --- .../inbox/2026-09-01-A57-agy-native-login.md | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 agents/inbox/2026-09-01-A57-agy-native-login.md diff --git a/agents/inbox/2026-09-01-A57-agy-native-login.md b/agents/inbox/2026-09-01-A57-agy-native-login.md new file mode 100644 index 0000000..0e54e0f --- /dev/null +++ b/agents/inbox/2026-09-01-A57-agy-native-login.md @@ -0,0 +1,169 @@ +# Задание A57: вход в Antigravity через сам agy, а не через сочинённый файл + +## Дата поступления +2026-09-01 + +## База + +`origin/main` (`f188a18`). + +``` +git fetch origin --prune +git checkout -b antigravity/a57-agy-native-login origin/main +``` + +В `main` напрямую не пушить. + +## Порядок исполнения + +Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-8** написан для аудитора. + +Зона: подключение аккаунтов Antigravity. С A56 (сжатие контекста) не пересекается. + +--- + +## Задача + +Сейчас хаб проводит вход сам: получает токены по OAuth и **записывает чужой файл учётных данных своим кодом**. Формат этого файла ревьюер восстановил по рабочему профилю владельца и по строкам в бинарнике `agy`. Сегодня это работает. Но `agy` обновляется, и формат может измениться молча: файл останется на месте, вход перестанет засчитываться, а владелец увидит ровно ту необъяснимую картину, которую ловили полдня — «Авторизация успешно завершена» и тут же «Please sign in to view available models». + +Правильный ответ — дать войти самому `agy`, с `HOME`, указывающим на каталог профиля. Тогда он пишет свои файлы своим форматом, сам обновляет токен по истечении часа, и гадать не о чем. + +--- + +## Что проверено ревьюером — заново не выяснять + +### Неинтерактивного входа у agy нет + +`agy --help` (проверено запуском) даёт подкоманды: `agent`, `agents`, `changelog`, `help`, `install`, `mcp`, `mic-serve`, `models`, `plugin`, `plugins`, `update`. Подкоманд `login` или `auth` **нет**. Вход один: запустить CLI без аргументов и пройти его в терминале. Это же говорит текст ошибки самого agy: + +``` +Error: Please sign in to view available models. +Launch the CLI without arguments to sign in. +``` + +Искать скрытый флаг входа не надо — его нет. + +### Что agy держит в каталоге профиля + +Рабочий профиль владельца `ag-orch-fallback` (11 моделей): + +``` +.gemini/oauth_creds.json 1949 байт формат Gemini CLI +.gemini/antigravity-cli/antigravity-oauth-token 505 байт {"auth_method":"consumer","token":{...}} +.gemini/antigravity-cli/settings.json 107 байт enableTelemetry, trustedWorkspaces +.gemini/antigravity-cli/installation_id 36 байт +.gemini/antigravity-cli/jetski_state.pbtxt +.gemini/antigravity-cli/history.jsonl +.gemini/antigravity-cli/conversation_summaries.db +auth.json файл хаба, не agy +``` + +Вход agy читает из `antigravity-oauth-token`, а не из `oauth_creds.json`. Это установлено сравнением рабочего профиля с неработающим и подтверждено исполнением: файл создали руками для `ag-4` — `agy models` тут же выдал 11 моделей. + +### Изоляция по HOME уже работает + +`get_profile_env_dir`, `build_safe_subprocess_env` и `hidden_process_kwargs` существуют и применяются: `agy models` вызывается с `HOME`/`USERPROFILE`, указывающими на каталог профиля. Заново это писать не надо. + +### Заготовка уже есть + +`launch_native_agy_login` в `agy_subprocess.py` запускает agy с `CREATE_NEW_CONSOLE`. Её никто не вызывает — числится мёртвым кодом с A54. Это отправная точка, а не мусор. + +### Рост номеров профилей починен + +Слот выбирался до входа, когда почта ещё неизвестна, и повторный вход тем же аккаунтом занимал очередной свободный номер: один аккаунт владельца расползся на ag-2, ag-3, ag-4. В `9641957` после опознания почты учётные данные возвращаются в слот, который этот аккаунт уже занимает. **Не переделывать.** + +### Среда владельца + +Сервер: Ubuntu 24.04, рабочий стол на месте (Chrome запускается), владелец сидит за машиной. Вторая машина — Windows 11. Хаб работает на обеих. + +--- + +## P0-1. Вход выполняет сам agy + +1. **Запуск `agy` в настоящем терминале** с `HOME` и `USERPROFILE`, указывающими на каталог выбранного профиля. Владелец проходит вход глазами и руками — это единственный доступный способ. +2. **Терминал не подразумевать, а искать.** На Linux проверить наличие эмулятора (`x-terminal-emulator`, `gnome-terminal`, `konsole`, `xfce4-terminal`, `xterm`) и назвать в отказе, что именно искали. Отсутствие терминала — честный отказ с перечнем проверенного, а не молчание. +3. **Файл учётных данных хаб больше не сочиняет.** `write_agy_oauth_creds` и запись `antigravity-oauth-token` остаются только для запасного браузерного пути (P0-4). +4. **Ждать окончания входа по появлению файла**, а не по коду возврата терминала: эмулятор часто отсоединяется сразу. Ждать `antigravity-oauth-token` в каталоге профиля с разумным пределом и внятным сообщением по его истечении. + +## P0-2. Слот выбирается до входа и не меняется + +1. **Каталог профиля определяется заранее** и передаётся через `HOME`. Вход физически не может уйти в чужой каталог — в этом весь смысл. +2. **Занятый слот не перезаписывать.** Если в каталоге уже лежит рабочий вход другого аккаунта, предупредить и потребовать подтверждения. +3. **Каталоги существующих аккаунтов не трогать.** Их два десятка, повторный вход руками стоит владельцу часов. + +## P0-3. Почта берётся из профиля, а не выдумывается + +1. **После входа опознать аккаунт**, прочитав то, что записал agy. Если почту установить не удалось — показать `Н/Д` с причиной, а не подставить правдоподобное. +2. **Проверить двойников** уже существующим `AutoAssigner.check_duplicate_identity` и вернуть учётные данные в занятый этим аккаунтом слот, если он есть. +3. **Число и время получения моделей** показывать рядом со списком. + +## P0-4. Браузерный путь остаётся запасным + +1. **Не удалять существующий OAuth.** С другой машины через браузер это единственный способ, и он работает. +2. **Владелец выбирает способ** в мастере: вход в терминале на этой машине или по ссылке из браузера. Предлагать первым тот, который на текущей машине выполним. +3. **Текст объясняет разницу**: терминал доступен только там, где стоит хаб. + +## P0-5. Отказ доходит до владельца + +1. **Причина отказа — текстом.** `agy` пишет её в stderr; она обязана попадать в интерфейс вместе с указанием `HOME`, с которым шёл запуск. +2. **Пустых сообщений быть не должно.** Запасной текст «Отказ выполнения действия» означает потерянную причину. +3. **Проверка после входа не блокирует ответ.** Действие возвращается сразу, опрос провайдера идёт в фоне — это сделано в `0ad946e`, не откатывать. + +## P0-6. Безопасность + +1. **Токены и коды в интерфейс не выводить и в журнал не писать.** В сообщениях допустимы пути и имена файлов, но не содержимое. +2. **Права на каталог и файлы** — `0700` и `0600`. +3. **`~/.hermes/agy_profiles/` не чистить** и не трогать чужие профили. + +## P0-7. Проверка исполнением + +Тестами это не ловится: все прежние дефекты входа прошли через зелёный прогон. + +1. **Подключить аккаунт через терминал на сервере** и приложить вывод `agy models` для этого профиля. +2. **Подключить второй аккаунт** и убедиться, что первый не задет: у обоих свои каталоги и свои почты. +3. **Повторить вход тем же аккаунтом** и убедиться, что новый слот не создаётся. +4. **Проверить отказ** при отсутствии терминала: сообщение перечисляет, что искали. +5. **Проверить, что браузерный путь по-прежнему работает** с другой машины. + +## P0-8. Аудит вторым проходом + +1. **Пройти вход целиком на обеих машинах**, скриншоты приложить. +2. **Убедиться, что хаб не пишет `antigravity-oauth-token`** на пути через CLI: файл создаёт agy. +3. **Проверить, что при отказе входа профиль не остаётся наполовину заполненным** и не числится подключённым. +4. **Различать «нет доступа» и «не найдено»** — не повторять ошибку ложного диагноза. +5. **Побочные изменения** объяснить. +6. **Пропущенный пункт назвать пропущенным.** + +--- + +## Ограничения + +- Учётные данные и каталоги существующих аккаунтов не трогать. +- Правки ревьюера из `main` не откатывать: `9641957` (запись токена и слоты), `0ad946e` (проверка в фоне), `f188a18` (устаревший вердикт). +- Фронтенд без npm, без сборки, без фреймворков — по `docs/web-api/CONTRACT.md` §1. +- Адреса, пути и имена терминалов в код не зашивать вслепую: искать и сообщать, что искали. +- Версию `0.1.3` не понижать. +- Правило честности без исключений: неизмеренное — `Н/Д` с причиной; отсутствие прав — не то же самое, что отсутствие файла. + +## Критерии приёмки + +1. Ветка в `origin`, `git status` чист. +2. Вход через `agy` в терминале работает на Linux и на Windows; вывод `agy models` приложен. +3. Файл `antigravity-oauth-token` на этом пути создаёт agy, а не хаб. +4. Слот задаётся до входа через `HOME`; чужой каталог затронуть невозможно. +5. Повторный вход тем же аккаунтом не создаёт новый слот. +6. Почта берётся из профиля; неустановленная показывается как `Н/Д` с причиной. +7. Браузерный путь сохранён и проверен с другой машины. +8. Отсутствие терминала даёт отказ с перечнем проверенного. +9. Токены не попадают ни в интерфейс, ни в журнал. +10. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **626**. +11. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. + +## Главное + +Хаб сегодня подделывает чужой формат учётных данных. Это работает ровно до следующего обновления `agy`, и отказ будет молчаливым: файл на месте, вход не засчитан, причина неочевидна. Владелец уже потерял на этом день. + +`agy` умеет входить сам и делает это правильно по определению. Задание переносит вход туда, где ему место, оставляя браузерный путь для удалённого случая. + +## Порядок сдачи +Передать точный `FINAL_COMMIT_SHA`.