merge main

This commit is contained in:
Hermes Team 2026-09-01 14:15:40 +07:00
commit d5c8c316b4
27 changed files with 1312 additions and 42 deletions

View file

@ -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`.

View file

@ -1,5 +1,5 @@
{ {
"hub_version": "0.1.2", "hub_version": "0.1.3",
"min_hermes_version": "0.20.0", "min_hermes_version": "0.20.0",
"max_tested_hermes_version": "0.20.4", "max_tested_hermes_version": "0.20.4",
"tested_versions": [ "tested_versions": [

View file

@ -14,11 +14,11 @@ namespace HermesHubSetup
{ {
public class SetupEngine public class SetupEngine
{ {
public const string HUB_VERSION = "0.1.2"; public const string HUB_VERSION = "0.1.3";
// Подставляется сборщиком из фактического git-коммита. Раньше здесь // Подставляется сборщиком из фактического git-коммита. Раньше здесь
// жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из // жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из
// какого кода собран установщик. // какого кода собран установщик.
public const string BuildCommit = "8daeafa"; public const string BuildCommit = "0ad946e";
public const string MIN_HERMES_VERSION = "0.20.0"; public const string MIN_HERMES_VERSION = "0.20.0";
public const string MAX_TESTED_HERMES = "0.20.4"; public const string MAX_TESTED_HERMES = "0.20.4";

View file

@ -7,10 +7,31 @@
set -e set -e
HUB_VERSION="0.1.2" HUB_VERSION="0.1.3"
DEFAULT_HERMES_HOME="$HOME/.hermes" DEFAULT_HERMES_HOME="$HOME/.hermes"
HERMES_HOME="${HERMES_HOME:-$DEFAULT_HERMES_HOME}" 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)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
@ -22,6 +43,60 @@ echo "Hermes Home : $HERMES_HOME"
echo "Source Root : $REPO_ROOT" echo "Source Root : $REPO_ROOT"
echo "" 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 # 1. Check / Discover Python Runtime and Hermes Environment
echo "[1/6] Checking Python and Hermes environment..." echo "[1/6] Checking Python and Hermes environment..."
PYTHON_BIN="" PYTHON_BIN=""
@ -30,6 +105,8 @@ if [ -f "$HERMES_HOME/hermes-agent/venv/bin/python3" ]; then
PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python3" PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python3"
elif [ -f "$HERMES_HOME/hermes-agent/venv/bin/python" ]; then elif [ -f "$HERMES_HOME/hermes-agent/venv/bin/python" ]; then
PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python" 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 elif command -v python3 >/dev/null 2>&1; then
PYTHON_BIN="$(command -v python3)" PYTHON_BIN="$(command -v python3)"
elif command -v python >/dev/null 2>&1; then elif command -v python >/dev/null 2>&1; then
@ -57,12 +134,48 @@ echo "[2/6] Verifying Python dependencies..."
DEPS_OK=true DEPS_OK=true
"$PYTHON_BIN" -c "import fastapi, uvicorn, pydantic, psutil, yaml; print('DEPS_OK')" >/dev/null 2>&1 || DEPS_OK=false "$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 if [ "$DEPS_OK" != "true" ]; then
echo " Installing required packages (fastapi, uvicorn, pydantic, psutil, pyyaml)..." # Системный python в Debian и Ubuntu помечен как externally-managed
"$PYTHON_BIN" -m pip install --no-warn-script-location -q fastapi uvicorn pydantic psutil pyyaml || { # (PEP 668) и отклоняет pip install — и обычный, и с --user. Обходить это
echo "⚠️ Warning: pip install returned non-zero code. Trying with --user..." # через --break-system-packages нельзя: имя флага не преувеличивает, так
"$PYTHON_BIN" -m pip install --user --no-warn-script-location -q fastapi uvicorn pydantic psutil pyyaml || true # ломают питон всей машины. Правильный ответ — собственный 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 fi
# 3. Mirror Plugin Files to ~/.hermes/plugins/antigravity-provider (with cleanup of stale files) # 3. Mirror Plugin Files to ~/.hermes/plugins/antigravity-provider (with cleanup of stale files)
@ -203,5 +316,12 @@ echo "To launch the Web Application window:"
echo " $LAUNCHER_BIN" echo " $LAUNCHER_BIN"
echo "" echo ""
echo "Or open 'Hermes Hub Web' from your Applications menu." 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 "======================================================================" echo "======================================================================"
exit 0 exit 0

Binary file not shown.

Binary file not shown.

View file

@ -16,6 +16,11 @@ if [ -f "$HERMES_HOME/hermes-agent/venv/bin/python3" ]; then
PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python3" PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python3"
elif [ -f "$HERMES_HOME/hermes-agent/venv/bin/python" ]; then elif [ -f "$HERMES_HOME/hermes-agent/venv/bin/python" ]; then
PYTHON_BIN="$HERMES_HOME/hermes-agent/venv/bin/python" 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 elif command -v python3 >/dev/null 2>&1; then
PYTHON_BIN="$(command -v python3)" PYTHON_BIN="$(command -v python3)"
elif command -v python >/dev/null 2>&1; then elif command -v python >/dev/null 2>&1; then

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "hermes-hub" name = "hermes-hub"
version = "0.1.2" version = "0.1.3"
description = "Multi-Agent & Multi-Provider Control Hub for Hermes Agent" description = "Multi-Agent & Multi-Provider Control Hub for Hermes Agent"
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }

View file

@ -46,12 +46,32 @@ def _find_agy_exe() -> str:
if env and Path(env).is_file(): if env and Path(env).is_file():
return env 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 from antigravity_provider.paths import get_hermes_home
exe_name = "agy.exe" if os.name == "nt" else "agy" exe_name = "agy.exe" if os.name == "nt" else "agy"
candidate = get_hermes_home().parent / "agy" / "bin" / exe_name candidates = [
if candidate.is_file(): get_hermes_home().parent / "agy" / "bin" / exe_name,
return str(candidate) 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 # 3. PATH
found = shutil.which("agy") or shutil.which("agy.exe") found = shutil.which("agy") or shutil.which("agy.exe")
@ -59,8 +79,9 @@ def _find_agy_exe() -> str:
return found return found
raise FileNotFoundError( raise FileNotFoundError(
"agy executable not found. Set the AGY_EXE_PATH environment " "Утилита agy не найдена. Проверено: "
"variable, install agy, or ensure it is on PATH." + "; ".join(checked)
+ "; и PATH процесса. Задайте путь переменной AGY_EXE_PATH либо установите agy."
) )
@ -207,7 +228,18 @@ def discover_models(profile_id: str | None = None) -> dict[str, str]:
raw = result.stdout.strip() raw = result.stdout.strip()
if not raw or result.returncode != 0: if not raw or result.returncode != 0:
if profile_id: 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 {}) return dict(_AGY_MODEL_CACHE or {})
except Exception: except Exception:
if profile_id: if profile_id:
@ -724,6 +756,31 @@ def agy_generate(
"--disable-slash-commands", "--disable-slash-commands",
"--print-timeout", f"{timeout}s", "--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: if agy_model:
cmd.extend(["--model", agy_model]) cmd.extend(["--model", agy_model])
if agy_effort: if agy_effort:

View file

@ -708,6 +708,50 @@ class ActionExecutor:
return {'ok': False, 'message': reason, 'data': {'status': status}} return {'ok': False, 'message': reason, 'data': {'status': status}}
return {'ok': True, 'message': 'Ожидание подтверждения', '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': if action == 'validate_connection':
from .connection_preflight import 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 '') return validate_connection(prov, data.get('token') or data.get('api_key') or '', data.get('base_url') or '', data.get('preferred_model') or '')
@ -857,7 +901,27 @@ class ActionExecutor:
if validation: if validation:
AccountProbeService.get().record_validation(prov_norm, slot, validation) AccountProbeService.get().record_validation(prov_norm, slot, validation)
return {'ok': True, 'message': validation['message'], 'data': {'profile_id': slot, 'models': validation['data']['models']}} 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 result.setdefault('data', {})['profile_id'] = slot
return result return result
except Exception as e: except Exception as e:

View file

@ -38,6 +38,20 @@ class NvidiaAdapter(BaseProviderAdapter):
if key: if key:
return str(key).strip() 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("-", "_") suffix = profile.profile_id.upper().replace("-", "_")
for candidate in (f"NVIDIA_API_KEY_{suffix}", "NVIDIA_API_KEY", "NV_API_KEY"): for candidate in (f"NVIDIA_API_KEY_{suffix}", "NVIDIA_API_KEY", "NV_API_KEY"):
val = os.environ.get(candidate, "").strip() val = os.environ.get(candidate, "").strip()

View file

@ -15,6 +15,7 @@ from .local_adapter import LocalLLMAdapter
logger = logging.getLogger("hermes.router.adapter.ollama") logger = logging.getLogger("hermes.router.adapter.ollama")
DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434" DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434"
CLOUD_OLLAMA_BASE_URL = "https://ollama.com"
DEFAULT_OLLAMA_MODELS = ["llama3:latest"] DEFAULT_OLLAMA_MODELS = ["llama3:latest"]
@ -25,11 +26,34 @@ class OllamaAdapter(LocalLLMAdapter):
and native Ollama endpoints (/api/tags). 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: 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 profile.custom_base_url
or profile.auth_config.get("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_BASE_URL")
or os.environ.get("OLLAMA_HOST") or os.environ.get("OLLAMA_HOST")
or DEFAULT_OLLAMA_BASE_URL or DEFAULT_OLLAMA_BASE_URL
@ -45,6 +69,18 @@ class OllamaAdapter(LocalLLMAdapter):
if key: if key:
return str(key).strip() 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("-", "_") suffix = profile.profile_id.upper().replace("-", "_")
for candidate in (f"OLLAMA_API_KEY_{suffix}", "OLLAMA_API_KEY", "OLLAMA_TOKEN"): for candidate in (f"OLLAMA_API_KEY_{suffix}", "OLLAMA_API_KEY", "OLLAMA_TOKEN"):
val = os.environ.get(candidate, "").strip() val = os.environ.get(candidate, "").strip()

View file

@ -41,6 +41,20 @@ class OpenRouterAdapter(BaseProviderAdapter):
if key: if key:
return str(key).strip() 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("-", "_") suffix = profile.profile_id.upper().replace("-", "_")
for candidate in (f"OPENROUTER_API_KEY_{suffix}", "OPENROUTER_API_KEY"): for candidate in (f"OPENROUTER_API_KEY_{suffix}", "OPENROUTER_API_KEY"):
val = os.environ.get(candidate, "").strip() val = os.environ.get(candidate, "").strip()

View file

@ -30,6 +30,20 @@ def validate_connection(provider, token="", base_url="", preferred_model=""):
raise ValueError("Для этого провайдера используйте вход через авторизацию") raise ValueError("Для этого провайдера используйте вход через авторизацию")
if provider not in ("local", "vllm", "ollama") and not token: if provider not in ("local", "vllm", "ollama") and not token:
raise ValueError("Не указан API-ключ") 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"} headers = {"Content-Type": "application/json"}
if provider == "claude": if provider == "claude":
headers.update({"x-api-key": token, "anthropic-version": "2023-06-01"}) headers.update({"x-api-key": token, "anthropic-version": "2023-06-01"})
@ -97,6 +111,41 @@ def validate_connection(provider, token="", base_url="", preferred_model=""):
except Exception as exc: except Exception as exc:
exc_str = str(exc) exc_str = str(exc)
exc_lower = exc_str.lower() 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")): 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 { return {
"ok": False, "ok": False,
@ -104,7 +153,9 @@ def validate_connection(provider, token="", base_url="", preferred_model=""):
"data": {"models": []}, "data": {"models": []},
} }
if isinstance(exc, urllib.error.HTTPError): 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: try:
body = json.loads(exc.read(4096).decode('utf-8', errors='replace')) body = json.loads(exc.read(4096).decode('utf-8', errors='replace'))
detail = body.get('error') or body.get('detail') detail = body.get('error') or body.get('detail')

View file

@ -0,0 +1,180 @@
"""Определение моделей, доступных конкретному аккаунту.
Каталог NVIDIA публичный: `GET /v1/models` отдаётся вообще без ключа и
возвращает один и тот же список всем. Полей о доступности в нём нет только
`id`, `object`, `created`, `owned_by`. Проверено запросом.
Значит узнать, чем может пользоваться конкретный аккаунт, из каталога нельзя.
Единственный достоверный способ спросить у самой модели. Недоступная
отвечает до генерации:
404 Function ... Not found for account '<id>'
то есть проверка недоступной модели не стоит ничего, а доступной один токен
при `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

View file

@ -15,7 +15,7 @@ import threading
import time import time
import urllib.request import urllib.request
import urllib.error import urllib.error
from datetime import datetime from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
@ -114,6 +114,76 @@ class ProfileAuthManager:
"""Official API to get isolated directory for a profile.""" """Official API to get isolated directory for a profile."""
return get_profile_dir(profile_id, provider) 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 @classmethod
def write_agy_oauth_creds(cls, profile_dir: Path, auth_data: dict) -> Path: def write_agy_oauth_creds(cls, profile_dir: Path, auth_data: dict) -> Path:
"""Atomically write <profile_dir>/.gemini/oauth_creds.json in exact agy CLI format.""" """Atomically write <profile_dir>/.gemini/oauth_creds.json in exact agy CLI format."""
@ -123,6 +193,14 @@ class ProfileAuthManager:
access_token = token_info.get("access_token") or auth_data.get("access_token") or "" 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 "" 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 "" 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" 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 "" id_token = token_info.get("id_token") or auth_data.get("id_token") or ""
@ -179,6 +257,8 @@ class ProfileAuthManager:
os.chmod(target_file, 0o600) os.chmod(target_file, 0o600)
except OSError: except OSError:
pass pass
cls._write_antigravity_cli_token(profile_dir, creds_dict)
return target_file return target_file
@staticmethod @staticmethod
@ -314,7 +394,16 @@ class ProfileAuthManager:
try: try:
cls.write_agy_oauth_creds(pdir, auth_data) cls.write_agy_oauth_creds(pdir, auth_data)
except Exception as e: 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 # 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"): if provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm", "openrouter", "nvidia", "nvidia-nim"):

View file

@ -255,6 +255,29 @@ class ProfileOAuthSession:
email = fetch_user_email(tokens["access_token"]) email = fetch_user_email(tokens["access_token"])
logger.info("OAuth account identity resolved (email_found=%s)", bool(email)) 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 # Format in standard gemini:antigravity shape
expires_at = tokens.get("expires_at") or (int(time.time()) + 3600) expires_at = tokens.get("expires_at") or (int(time.time()) + 3600)
auth_data = { auth_data = {

View file

@ -918,6 +918,31 @@ class AccountQuotaService:
def _generate_baseline_snapshot(self, provider: str, profile_id: str) -> QuotaSnapshot: def _generate_baseline_snapshot(self, provider: str, profile_id: str) -> QuotaSnapshot:
"""Truthful offline baseline with provider-specific independent limit pools.""" """Truthful offline baseline with provider-specific independent limit pools."""
now = _utc_now() 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"): if provider in ("local", "local-llm", "llama.cpp", "ollama", "vllm"):
b_unlimited = QuotaBucket( b_unlimited = QuotaBucket(
id="local.unlimited", id="local.unlimited",

View file

@ -543,7 +543,25 @@ class UnifiedHealthService:
if check.get("state") == "checking": if check.get("state") == "checking":
health_state, health_lbl = "checking", "Проверяется…" health_state, health_lbl = "checking", "Проверяется…"
elif check.get("state") == "failed": 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): elif check.get("state") == "working" and health_state in (STATUS_NOT_TESTED, STATUS_HEALTHY):
health_state, health_lbl = STATUS_HEALTHY, "Проверен: работает" health_state, health_lbl = STATUS_HEALTHY, "Проверен: работает"
assigned = role_assignments.get(pid, []) assigned = role_assignments.get(pid, [])

View file

@ -23,7 +23,7 @@ from antigravity_provider.router.state_store import HubStateStore
from antigravity_provider.router.action_handler import ActionExecutor from antigravity_provider.router.action_handler import ActionExecutor
from antigravity_provider.router.router_config import load_router_config 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") logger = logging.getLogger("hermes.router.web")
@ -489,6 +489,10 @@ def get_settings(authorized: bool = Depends(get_auth_token)):
"config_dir": system_paths["config_dir"], "config_dir": system_paths["config_dir"],
"log_file": system_paths["log_file"], "log_file": system_paths["log_file"],
"installed_commit": get_installed_commit(), "installed_commit": get_installed_commit(),
# Версия между сборками не меняется намеренно, а коммит — строка из
# шестнадцатеричных цифр, по которой трудно на глаз отличить старую
# сборку от новой. Время установки отвечает на этот вопрос сразу.
"installed_at": get_installed_build_time(),
"version": __version__, "version": __version__,
"last_update_check": last_check.to_dict() if last_check else None, "last_update_check": last_check.to_dict() if last_check else None,
"network_security": { "network_security": {
@ -586,6 +590,23 @@ if _STATIC_DIR.is_dir():
headers=_NO_CACHE, 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") @app.get("/snapshot.example.json")
def _fixture(): def _fixture():
return FileResponse(str(_STATIC_DIR / "snapshot.example.json"), media_type="application/json") return FileResponse(str(_STATIC_DIR / "snapshot.example.json"), media_type="application/json")

View file

@ -570,15 +570,32 @@ async function executeAction(actionName, actionData = {}) {
} }
// ── GLOBAL HEADER ── // ── 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() { function updateGlobalHeader() {
if (!currentSnapshot) return; if (!currentSnapshot) return;
const readiness = currentSnapshot.readiness || {}; const readiness = currentSnapshot.readiness || {};
renderAccountSummary(currentSnapshot); renderAccountSummary(currentSnapshot);
const allProfiles = Object.values(currentSnapshot.all_profiles || {}); const allProfiles = Object.values(currentSnapshot.all_profiles || {});
const connectedAccounts = readiness.accounts_connected_count ?? allProfiles.filter( // Одно число — одно определение. Готовность считает строго AUTHENTICATED,
(p) => isConnectedProfile(p) // а страница аккаунтов — всё, что не NOT_CONFIGURED. Из-за двух определений
).length; // значок в меню показывал 9, а карточка на той же странице — 3.
// Берём то же правило, что и страница аккаунтов: расходиться они не должны.
const connectedAccounts = allProfiles.filter((p) => isConnectedProfile(p)).length;
if (elements.navAccountsCount) elements.navAccountsCount.textContent = connectedAccounts; if (elements.navAccountsCount) elements.navAccountsCount.textContent = connectedAccounts;
@ -629,7 +646,7 @@ function updateGlobalHeader() {
const curVer = (currentSnapshot && (currentSnapshot.version || (currentSnapshot.metrics || {}).version)) || (currentSettings && currentSettings.version) || ''; const curVer = (currentSnapshot && (currentSnapshot.version || (currentSnapshot.metrics || {}).version)) || (currentSettings && currentSettings.version) || '';
const versionTag = document.getElementById('version-tag'); const versionTag = document.getElementById('version-tag');
if (versionTag) { if (versionTag) {
versionTag.textContent = curVer ? `Hermes Hub Web v${curVer}` : 'Hermes Hub Web — Н/Д: версия не передана сервером'; versionTag.textContent = versionTagText(curVer);
} }
} }
@ -878,9 +895,23 @@ function renderAccountCheck(profile) {
<details ${models.length <= 16 ? 'open' : ''}><summary>Каталог моделей (${models.length})</summary><div class="account-models">${models.map(modelBrandLabel).join('')}</div></details> <details ${models.length <= 16 ? 'open' : ''}><summary>Каталог моделей (${models.length})</summary><div class="account-models">${models.map(modelBrandLabel).join('')}</div></details>
${profile.provider === 'ollama' ? `<p>Выше — модели указанного сервера Ollama.</p><p>Облачный каталог Ollama: ${meta.cloud?.error ? 'Н/Д — ' + escapeHtml(meta.cloud.error) : meta.cloud?.models ? escapeHtml(meta.cloud.models.join(', ')) : 'Н/Д — ещё не получен'}</p><p>Доступ аккаунта к облачным моделям: Н/Д до успешного вызова. Для прямого вызова нужен API-ключ Ollama; для локального клиента — вход через ollama signin.</p>` : ''} ${profile.provider === 'ollama' ? `<p>Выше — модели указанного сервера Ollama.</p><p>Облачный каталог Ollama: ${meta.cloud?.error ? 'Н/Д — ' + escapeHtml(meta.cloud.error) : meta.cloud?.models ? escapeHtml(meta.cloud.models.join(', ')) : 'Н/Д — ещё не получен'}</p><p>Доступ аккаунта к облачным моделям: Н/Д до успешного вызова. Для прямого вызова нужен API-ключ Ollama; для локального клиента — вход через ollama signin.</p>` : ''}
<button class="btn btn-ghost btn-sm" ${checking ? 'disabled' : ''} onclick="event.stopPropagation(); handleAccountProbe('${escapeHtml(profile.profile_id)}')">${checking ? 'Проверяется…' : 'Проверить подключение'}</button> <button class="btn btn-ghost btn-sm" ${checking ? 'disabled' : ''} onclick="event.stopPropagation(); handleAccountProbe('${escapeHtml(profile.profile_id)}')">${checking ? 'Проверяется…' : 'Проверить подключение'}</button>
${['nvidia', 'nvidia-nim', 'openrouter'].includes(String(profile.provider || '').toLowerCase())
? `<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation(); handleProbeAccountModels('${escapeHtml(profile.profile_id)}', '${escapeHtml(profile.provider)}')" title="Каталог провайдера общий для всех и о правах аккаунта не сообщает. Доступность выясняется опросом моделей и расходует вызовы.">Определить доступные модели</button>`
: ''}
</div>`; </div>`;
} }
// Каталог 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) { async function handleAccountProbe(profileId) {
await executeAction('check_account', {profile_id: profileId}); await executeAction('check_account', {profile_id: profileId});
await fetchSnapshot(); await fetchSnapshot();
@ -2434,7 +2465,7 @@ function renderUpdateUI() {
} }
const versionTag = document.getElementById('version-tag'); const versionTag = document.getElementById('version-tag');
if (versionTag) { if (versionTag) {
versionTag.textContent = curVer ? `Hermes Hub Web v${curVer}` : 'Hermes Hub Web — Н/Д: версия не передана сервером'; versionTag.textContent = versionTagText(curVer);
} }
if (statusBadge) { if (statusBadge) {
@ -3163,7 +3194,15 @@ async function proceedToWizardStep3(providerId) {
if (tokenInput || baseInput) { if (tokenInput || baseInput) {
const feedback = document.getElementById('modal-feedback-area'); const feedback = document.getElementById('modal-feedback-area');
if (feedback) feedback.textContent = 'Проверка подключения и запрос моделей…'; 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 (!result?.ok) {
if (feedback) feedback.textContent = result?.message || 'Нет ответа от сервера'; if (feedback) feedback.textContent = result?.message || 'Нет ответа от сервера';
return; return;
@ -3250,7 +3289,10 @@ async function finishAddAccount(providerId) {
const feedbackArea = document.getElementById('modal-feedback-area'); const feedbackArea = document.getElementById('modal-feedback-area');
if (feedbackArea) { if (feedbackArea) {
feedbackArea.innerHTML = `<div class="modal-feedback info">⏳ ${escapeHtml(providerId)}: сохранение аккаунта и запуск проверки. Опрос провайдера может занять до минуты на этап.</div>`; // Обещать «до минуты на этап» было неправдой: проверка Antigravity через
// CLI занимала до четырёх минут, и мастер выглядел зависшим. Теперь
// действие возвращается сразу, а проверка идёт в фоне.
feedbackArea.innerHTML = `<div class="modal-feedback info">⏳ ${escapeHtml(providerId)}: сохраняем аккаунт…</div>`;
} }
const payload = { const payload = {

View file

@ -1,8 +1,8 @@
"""Single Source of Truth for Hermes Hub Versioning.""" """Single Source of Truth for Hermes Hub Versioning."""
from __future__ import annotations from __future__ import annotations
__version__ = "0.1.2" __version__ = "0.1.3"
VERSION_INFO = (0, 1, 2) VERSION_INFO = (0, 1, 3)
CHANNEL = "stable" CHANNEL = "stable"
MINIMUM_HERMES_VERSION = "0.20.0" MINIMUM_HERMES_VERSION = "0.20.0"

View file

@ -132,31 +132,35 @@ def test_p0_3_antigravity_dynamic_model_discovery():
# ── P0-4: Version Info & API Propagation Tests ── # ── P0-4: Version Info & API Propagation Tests ──
def test_p0_4_version_single_source_of_truth(): def test_p0_4_version_single_source_of_truth():
"""Version must be 0.1.2 and VERSION_INFO must be (0, 1, 2).""" """Номер версии и VERSION_INFO обязаны совпадать между собой.
assert __version__ == "0.1.2"
assert VERSION_INFO == (0, 1, 2) Сверять с записанным в тесте числом бессмысленно: при каждой сборке его
пришлось бы править, и тест превращался бы в напоминание, а не в проверку.
Смысл требования единый источник, его и проверяем.
"""
assert VERSION_INFO == tuple(int(part) for part in __version__.split("."))
def test_p0_4_version_in_api_endpoints(): 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) client = TestClient(app)
# /api/health # /api/health
res_health = client.get("/api/health") res_health = client.get("/api/health")
assert res_health.status_code == 200 assert res_health.status_code == 200
assert res_health.json().get("version") == "0.1.2" assert res_health.json().get("version") == __version__
# /api/settings # /api/settings
res_settings = client.get("/api/settings") res_settings = client.get("/api/settings")
assert res_settings.status_code == 200 assert res_settings.status_code == 200
assert res_settings.json().get("version") == "0.1.2" assert res_settings.json().get("version") == __version__
# /api/snapshot # /api/snapshot
res_snap = client.get("/api/snapshot") res_snap = client.get("/api/snapshot")
assert res_snap.status_code == 200 assert res_snap.status_code == 200
snap = res_snap.json() snap = res_snap.json()
assert snap.get("version") == "0.1.2" assert snap.get("version") == __version__
assert (snap.get("metrics") or {}).get("version") == "0.1.2" assert (snap.get("metrics") or {}).get("version") == __version__
# ── P0-5: Settings View & System Paths Tests ── # ── P0-5: Settings View & System Paths Tests ──

View file

@ -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"

View file

@ -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()

View file

@ -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) 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}" 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, где зависимостей нет"
)

View file

@ -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