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`. 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..ac8a6bf 100644 --- a/installer/HermesHubSetup.cs +++ b/installer/HermesHubSetup.cs @@ -14,11 +14,11 @@ namespace HermesHubSetup { public class SetupEngine { - public const string HUB_VERSION = "0.1.2"; + public const string HUB_VERSION = "0.1.3"; // Подставляется сборщиком из фактического git-коммита. Раньше здесь // жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из // какого кода собран установщик. - public const string BuildCommit = "8daeafa"; + 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/installer/install-linux.sh b/installer/install-linux.sh index cd1bb27..3f9556d 100644 --- a/installer/install-linux.sh +++ b/installer/install-linux.sh @@ -7,10 +7,31 @@ set -e -HUB_VERSION="0.1.2" +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)" @@ -22,6 +43,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="" @@ -30,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 @@ -57,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) @@ -203,5 +316,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 diff --git a/launcher/HermesHub.exe b/launcher/HermesHub.exe index 1f154d0..0d53459 100644 Binary files a/launcher/HermesHub.exe and b/launcher/HermesHub.exe differ diff --git a/launcher/HermesHubWeb.exe b/launcher/HermesHubWeb.exe index fcf1375..71bd585 100644 Binary files a/launcher/HermesHubWeb.exe and b/launcher/HermesHubWeb.exe differ 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/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/agy_subprocess.py b/src/antigravity_provider/agy_subprocess.py index bf59474..1769767 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." ) @@ -207,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: @@ -724,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/action_handler.py b/src/antigravity_provider/router/action_handler.py index 1fb5f27..0f55dde 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -708,6 +708,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 '') @@ -857,7 +901,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/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/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/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/connection_preflight.py b/src/antigravity_provider/router/connection_preflight.py index e1baee9..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"}) @@ -97,6 +111,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, @@ -104,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') 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/profile_manager.py b/src/antigravity_provider/router/profile_manager.py index 174a1c8..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.""" @@ -123,6 +193,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 "" @@ -179,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 @@ -314,7 +394,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/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/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", 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/src/antigravity_provider/router/web/server.py b/src/antigravity_provider/router/web/server.py index 9bd99b1..570619d 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") @@ -489,6 +489,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": { @@ -586,6 +590,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") diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index e06082e..e30f243 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -570,15 +570,32 @@ 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; 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; @@ -629,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); } } @@ -878,9 +895,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(); @@ -2434,7 +2465,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) { @@ -3163,7 +3194,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; @@ -3250,7 +3289,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/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_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" 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() 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, где зависимостей нет" + ) 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