fix(a54): validate accounts synchronously and manage Windows runtime lifecycle

This commit is contained in:
ochenstarik-ui 2026-08-31 20:54:19 +07:00
parent f0d06e4994
commit ddeba2db0e
31 changed files with 1387 additions and 245 deletions

View file

@ -0,0 +1,169 @@
# Задание A54: проверка аккаунтов не работает, окна консоли, закрытие программы
## Дата поступления
2026-08-31
## База
`origin/main` (`f0d06e4`).
```
git fetch origin --prune
git checkout -b antigravity/a54-accounts-fix origin/main
```
В `main` напрямую не пушить.
## Порядок исполнения
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-8** написан для аудитора.
**Задание срочное.** После установки сборки `f0d06e4` владелец не может пользоваться программой: ни один аккаунт не проверяется, модели не подтягиваются, поверх окна выскакивают чёрные консоли.
---
## Задача
Владелец, дословно: «я так понял ни один аккаунт не подключается. Все проверки проходят с ошибкой, модели перестали нормально подтягиваться. Даже на локальных моделях». И отдельно: «в чём сложность-то?» — про OpenRouter и NVIDIA, которые не подключаются третье задание подряд.
---
## Что проверено ревьюером — заново не выяснять
### Кнопка «Проверить подключение» ничего не проверяет
Воспроизведено вызовом:
```
check_account profile_id=local-1
→ ok=False
→ «Фоновая служба проверки не запущена. Перезапустите веб-сервер.»
```
Действие **перекладывает работу на фоновую службу** вместо того, чтобы выполнить проверку. Если служба не поднялась, владелец получает отказ на каждом аккаунте. В интерфейсе это выглядит как «Тест завершился с ошибкой» и «Отказ выполнения действия» — второе вообще запасной текст на случай пустого сообщения, то есть причина до владельца не доходит.
Служба включается в `web/server.py:496` внутри фонового потока. Любой сбой этого потока оставляет все проверки нерабочими, и узнать об этом нельзя.
### Удаление аккаунта занимает полминуты
Причина найдена: `_rescan_after_auth()` вызывает
```python
HubStateStore.get().refresh(force_scan=True)
AccountProbeService.get().schedule_all()
```
то есть **принудительный полный пересбор всех провайдеров** с сетевыми запросами. Таймауты в сборщике квот — 15, 20 и 30 секунд, у Antigravity через CLI — 60. Удаление одного ключа ждёт опроса всех.
### Окна консоли
В `f0d06e4` скрытие окна добавлено к двум живым запускам `agy` и к остальным фоновым вызовам. Проверено, что `hidden_process_kwargs()` на Windows возвращает `CREATE_NO_WINDOW` и `SW_HIDE`.
Окна у владельца остались. Наиболее вероятная причина: **старый процесс сервера пережил обновление**. Закрытие окна браузера сервер не останавливает, и после установки продолжает работать прежний код. Проверить это первым делом.
`launch_native_agy_login` с `CREATE_NEW_CONSOLE` — мёртвый код, его никто не вызывает. Либо удалить, либо подключить к входу.
### OpenRouter и NVIDIA
Сохранение работает — проверено вызовом `add_account`, профиль создаётся с верным идентификатором, чужой слот отклоняется. Значит дело не в сохранении, а в том, что **после ввода ключа ничего не проверяется и модели не запрашиваются**, и владелец остаётся с пустым списком.
---
## P0-1. Проверка выполняется, а не делегируется
1. **«Проверить подключение» делает настоящий запрос к провайдеру здесь и сейчас** и возвращает результат. Фоновая служба — для периодической проверки, а не для ручной.
2. **Отказ невозможен из-за незапущенной службы.** Если фоновая служба нужна, но не работает, ручная проверка всё равно обязана отработать.
3. **Причина доходит до владельца.** Запасной текст «Отказ выполнения действия» означает пустое сообщение — таких путей быть не должно.
4. **Состояние службы видно** в «Состоянии системы»: работает или нет, когда был последний обход.
## P0-2. Удаление и очистка
1. **Удаление ключа не запускает полный пересбор.** Обновлять только затронутый профиль; полный обход — в фон, не блокируя ответ.
2. **Кнопка «Очистить все аккаунты»** с подтверждением и перечислением того, что будет удалено.
3. **Учётные данные Antigravity — под защитой A37.** Массовое удаление не должно затрагивать `~/.hermes/agy_profiles/` без явного отдельного подтверждения: повторный вход в два десятка аккаунтов делается вручную и стоит владельцу часов.
## P0-3. Закрытие программы на Windows
Владелец: «при нажатии на крестик спрашивать, закрыть программу или свернуть в фон. При закрытии полностью всё закрывает».
1. **Диалог при закрытии**: закрыть полностью или свернуть в фон.
2. **Закрытие останавливает всё**: веб-сервер, фоновые опросы, дочерние процессы. После этого окон появляться не должно.
3. **Свёрнутое состояние видно** — значок в области уведомлений с пунктами «Открыть» и «Выход».
4. **Обновление не должно оставлять старый процесс**: перед установкой прежний сервер останавливается. Это вероятная причина того, что окна не исчезли после установки исправления.
## P0-4. Подключение по ключу проверяется сразу
Для `openrouter`, `nvidia`, `nvidia-nim` и прочих провайдеров с ключом:
1. **После ввода ключа — немедленная проверка**: запрос к провайдеру, и его ответ показывается владельцу.
2. **Ключ неверен — сказать сразу**, не создавая профиль-пустышку.
3. **Ключ верен — тут же запросить модели** и дать выбрать предпочитаемую в том же окне.
4. **Не «сохранено», а «подключено и проверено»** — сообщение должно отражать, что именно произошло.
## P0-5. Модели у локальных и Ollama
1. **«Запросить список моделей» у локального профиля возвращает ошибку** — разобраться и починить. Локальный путь не требует ключа, отказ там означает дефект, а не отсутствие доступа.
2. **Ollama: список не грузится.** Локальные модели через `/api/tags` по адресу профиля; облачный каталог уже работает — не сломать.
3. **Отличать «сервер не отвечает» от «моделей нет»**: у владельца на Windows Ollama не запущена, и `WinError 10061` — это честный ответ, его надо показывать именно так, а не как ошибку обновления.
## P0-6. Antigravity: было 14 моделей, стало 3
На прошлой сборке у аккаунтов Antigravity значилось «Получено 14 моделей» с перечнем. Сейчас в карточке три, статус «Не проверялся», а проверка завершается ошибкой.
1. **Найти, где список сузился.** Проверить, не подменяется ли обнаруженный список предпочтениями профиля — эта ошибка уже была в инспекторе агента и чинилась в правках ревьюера.
2. **Число и время получения показывать** рядом со списком, как было.
## P0-7. Проверка исполнением
Тестов недостаточно: все перечисленные дефекты прошли через зелёный прогон.
1. **Открыть хаб и нажать «Проверить подключение»** на локальном профиле, на Ollama и на Antigravity. Результат приложить скриншотами.
2. **Подключить OpenRouter с заведомо неверным ключом** и убедиться, что ошибка видна сразу; затем убедиться, что при верном ключе подтягиваются модели.
3. **Удалить аккаунт и замерить время** — должно быть быстро, без ожидания опроса всех провайдеров.
4. **Закрыть программу крестиком**, выбрать «закрыть», и убедиться, что процессов не осталось и окна не появляются.
5. **Проверить, что после обновления старый процесс не остаётся.**
## P0-8. Аудит вторым проходом
1. **Проверить, что ручная проверка работает при остановленной фоновой службе.**
2. **Искать оставшиеся пути с пустым сообщением об ошибке** — их не должно быть.
3. **Замерить удаление аккаунта** независимо.
4. **Проверить, что массовая очистка не трогает `~/.hermes/agy_profiles/`.**
5. **Проверить, что окна консоли не появляются** при работающей автопроверке.
6. **Побочные изменения** объяснить.
7. **Пропущенный пункт назвать пропущенным.**
---
## Ограничения
- Учётные данные и `~/.hermes/agy_profiles/` не трогать; массовое удаление — только с отдельным подтверждением.
- Автоматическую проверку не отключать ради тишины: чинить, а не убирать.
- Вёрстку A48 не ломать.
- Версию `0.1.1` не поднимать.
- Правило честности без исключений: причина отказа доходит до владельца текстом.
## Критерии приёмки
1. Ветка в `origin`, `git status` чист.
2. Ручная проверка выполняет запрос и возвращает результат даже при незапущенной фоновой службе; проверено.
3. Путей с пустым сообщением об ошибке не осталось.
4. Удаление аккаунта не ждёт полного обхода провайдеров; время замерено до и после.
5. Есть кнопка очистки всех аккаунтов с подтверждением; учётные данные Antigravity не затрагиваются без отдельного согласия.
6. Крестик спрашивает «закрыть или свернуть»; закрытие останавливает сервер и фоновые опросы; проверено отсутствием процессов.
7. Обновление не оставляет старый процесс.
8. Ввод ключа сразу проверяется, модели подтягиваются в том же окне.
9. Список моделей у локального профиля и Ollama работает; «сервер не отвечает» отличается от «моделей нет».
10. У Antigravity список моделей вернулся к полному; показано число и время получения.
11. Скриншоты проверок приложены.
12. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **574**.
13. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
## Главное
Владелец поставил сборку и не может ей пользоваться: проверка отказывает на каждом аккаунте, модели не грузятся даже у локальных, удаление ключа занимает полминуты, а поверх окна выскакивают консоли.
Общее у большинства этих дефектов одно: **действие не делает работу само, а перекладывает её на фоновую службу или на полный обход всех провайдеров**. Отсюда и отказы, и задержки. Чинить надо это, а не симптомы.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

View file

@ -0,0 +1,40 @@
# A54 — проверки аккаунтов и завершение приложения
Дата: 2026-08-31. START_HEAD / origin/main на старте: `f0d06e499449564b3fb19c80a8bcd862ea895594`.
Ветка: `antigravity/a54-accounts-fix`. Версия остаётся 0.1.1. Точный FINAL_COMMIT_SHA будет указан при сдаче и в PR после окончательной проверки.
## Что изменено
- Ручная проверка выполняет запрос независимо от фонового планировщика; результаты и ошибки возвращаются сразу. Проверки одного профиля сериализованы, незавершённая inference после таймаута не запускается повторно.
- Модели запрашиваются отдельным действием без inference. Пустой каталог отличается от ошибки соединения. Облачный каталог Ollama сохранён.
- Новый ключ проверяется до создания профиля. OpenRouter: аутентифицированный `/key`, затем каталог. NVIDIA: каталог и минимальный запрос обнаруженной чат-модели. Ошибки не записывают профиль. В мастере можно выбрать полученную модель, результат сохраняется в состоянии проверки.
- Удаление и смена модели используют локальные изменения снапшота; подключение не ожидает общего опроса. Массовая очистка показывает точный список, требует подтверждения, отклоняет устаревший список, исключает Antigravity и ссылки на защищённые данные.
- AG: явный запрос каталога больше не возвращает пожизненный глобальный кэш другого профиля. Предпочтения подписаны отдельно; каталог не обрезается до восьми, видны число и время. Падение с 14 до 3 на машине владельца не воспроизведено напрямую: найдены глобальный кэш и отдельная строка предпочтений, оба исправлены без заявления о доказанной единственной причине.
- Windows: контроллер с tray «Открыть / Выход», выбор полного завершения при закрытии окна приложения; остановка процессов только этой установки. Установщик/PowerShell останавливают старое дерево перед копированием, сохраняя ветвь самого установщика; при обновлении установщик отвечает за перезапуск. Мёртвый запуск отдельной консоли AG удалён. Вывод сервера читается постоянно, чтобы перенаполненный pipe не останавливал сервер.
## Проверки и их пределы
- Linux: **598 passed / 1 skipped / 0 failed**, 4 deselected. Пропуск — Windows C# compiler отсутствует. `ruff check .`, Node DOM contracts и `node --check` успешны.
- Router verification **10/10**; release gate успешен. Итоговый Windows CI проверяется через draft PR, результат будет дописан после выполнения.
- Браузер: настоящий запрос к Qwen на локальном 8081; AG — явно синтетический профиль с 14 моделями; Ollama — HTTP стенд с пустым `/api/tags` и заведомо недоступный порт. Кнопки ручной проверки нажаты.
- OpenRouter в браузере: немедленный HTTP 401 при неверном тестовом ключе, успешный HTTP стенд возвращает каталог и выбор модели в том же мастере. Настоящий ключ OpenRouter/NVIDIA владельца не использовался.
- Удаление, 50 образцов обработчика: базовая медиана **0.329 мс**, A54 **0.324 мс**; максимумы 6.942 / 63.205 мс. Это не доказательство ускорения пользовательского сценария: полуминутную задержку Windows воспроизвести здесь нельзя. Лишние полные сканирования в последующих операциях устранены отдельно. См. `deletion-benchmark.json`.
- Защита AG, ссылки, устаревший preview, ручная проверка при disabled, непустые ошибки, serialization и сохранение результата проверены тестами в изолированных каталогах.
## Не подтверждено исполнением
Windows: диалог крестика, tray, отсутствие оставшихся процессов/консолей и обновление поверх запущенной старой установки требуют интерактивной проверки Windows. Компиляция/CI не заменяют её. В fallback обычного браузера его вкладка не отслеживается как окно приложения; выход доступен через tray.
Не проверены реальные OAuth/каталог аккаунта Antigravity владельца и действующие ключи OpenRouter/NVIDIA. Учётные данные владельца не читались и не изменялись. A54 нельзя считать полностью принятой до этих проверок.
## Артефакты
- `local-live.png` — живой локальный сервер.
- `antigravity-fixture.png` — 14 синтетических моделей (не доказательство реального AG).
- `ollama-fixture.png` — пустой каталог и отказ соединения.
- `openrouter-invalid.png`, `openrouter-valid-fixture.png` — отказ и каталог HTTP стенда.
- `service-health.png` — состояние периодической службы.
- `tests/manual/a54_preview.py` — воспроизводимый изолированный стенд.
- `local-model-review.md`, `local-model-usage.json` — честный результат локальной делегации.
Проверенные первичные описания API: [OpenRouter current key](https://openrouter.ai/docs/api/api-reference/api-keys/get-current-key), [NVIDIA LLM API](https://docs.api.nvidia.com/nim/reference/llm-apis).

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View file

@ -0,0 +1,12 @@
{
"baseline_f0d06e4": {
"median_ms": 0.329,
"max_ms": 6.942,
"samples": 50
},
"a54": {
"median_ms": 0.324,
"max_ms": 63.205,
"samples": 50
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View file

@ -0,0 +1,13 @@
# A54 — локальная оркестрация
Использованы HTTP chat completions на 8082 (Qwen3-4B-Instruct-2507) и 8081 (Qwen3-Coder-30B-A3B), последовательно, без передачи данных владельца. Изолированный Git worktree; ответы моделей не исполнялись автоматически.
Циклы: probe-coder → probe-review → rework → review → rework; дополнительная передача сильному кодеру; preflight-coder → review → rework; Windows helper → review; отдельная генерация теста и ревью. Объёмы и время: `local-model-usage.json` (только сохранённые ответы; один потерянный ответ из-за ошибки оркестрационного скрипта не включён).
## Итог аудита Codex
Модели не довели критические части до приемлемого состояния самостоятельно. 4B повторно оставляла отказ при `enabled=False`, использовала несуществующий `threading.ThreadPoolExecutor`, неправильно читала JSON Ollama. 30B тоже предлагала фиктивные профили и успешную проверку без вызова inference. Эти версии отклонены; конечный код существенно переработан Codex.
Ревью 30B полезно для поиска отдельных дефектов, но содержит ложные срабатывания. Например, оно объявляло нестабильным `setdefault` словаря блокировок под mutex и не признало отсутствие нужного импорта в предложенном тесте. Его `PASS` не принимался как достаточное основание.
Исправленная схема на этой задаче: локальные кандидаты → статическая проверка Codex → исправления → детерминированные тесты → браузерное исполнение → Windows CI. Нельзя утверждать, что расходы Codex снизились: контрольного замера без делегации нет.

View file

@ -0,0 +1,198 @@
[
{
"step": "preflight-coder",
"model": "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
"seconds": 13.08,
"usage": {
"completion_tokens": 1490,
"prompt_tokens": 394,
"total_tokens": 1884,
"prompt_tokens_details": {
"cached_tokens": 10
}
},
"finish_reason": "stop"
},
{
"step": "preflight-review",
"model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
"seconds": 5.61,
"usage": {
"completion_tokens": 333,
"prompt_tokens": 1536,
"total_tokens": 1869,
"prompt_tokens_details": {
"cached_tokens": 5
}
},
"finish_reason": "stop"
},
{
"step": "preflight-rework",
"model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
"seconds": 19.48,
"usage": {
"completion_tokens": 1705,
"prompt_tokens": 1849,
"total_tokens": 3554,
"prompt_tokens_details": {
"cached_tokens": 3
}
},
"finish_reason": "stop"
},
{
"step": "probe-coder-1",
"model": "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
"seconds": 16.74,
"usage": {
"completion_tokens": 1823,
"prompt_tokens": 1149,
"total_tokens": 2972,
"prompt_tokens_details": {
"cached_tokens": 0
}
},
"finish_reason": "stop"
},
{
"step": "probe-review-1",
"model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
"seconds": 5.89,
"usage": {
"completion_tokens": 603,
"prompt_tokens": 2059,
"total_tokens": 2662,
"prompt_tokens_details": {
"cached_tokens": 2058
}
},
"finish_reason": "stop"
},
{
"step": "probe-review-2",
"model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
"seconds": 7.71,
"usage": {
"completion_tokens": 511,
"prompt_tokens": 2115,
"total_tokens": 2626,
"prompt_tokens_details": {
"cached_tokens": 312
}
},
"finish_reason": "stop"
},
{
"step": "probe-rework-1",
"model": "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
"seconds": 19.38,
"usage": {
"completion_tokens": 1879,
"prompt_tokens": 2643,
"total_tokens": 4522,
"prompt_tokens_details": {
"cached_tokens": 10
}
},
"finish_reason": "stop"
},
{
"step": "probe-rework-2",
"model": "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
"seconds": 22.35,
"usage": {
"completion_tokens": 2163,
"prompt_tokens": 2607,
"total_tokens": 4770,
"prompt_tokens_details": {
"cached_tokens": 291
}
},
"finish_reason": "stop"
},
{
"step": "probe-root-audit",
"model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
"seconds": 6.82,
"usage": {
"completion_tokens": 490,
"prompt_tokens": 1488,
"total_tokens": 1978,
"prompt_tokens_details": {
"cached_tokens": 4
}
},
"finish_reason": "stop"
},
{
"step": "probe-strong",
"model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
"seconds": 24.7,
"usage": {
"completion_tokens": 2082,
"prompt_tokens": 2439,
"total_tokens": 4521,
"prompt_tokens_details": {
"cached_tokens": 5
}
},
"finish_reason": "stop"
},
{
"step": "test-coder",
"model": "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
"seconds": 2.67,
"usage": {
"completion_tokens": 302,
"prompt_tokens": 143,
"total_tokens": 445,
"prompt_tokens_details": {
"cached_tokens": 3
}
},
"finish_reason": "stop"
},
{
"step": "test-review",
"model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
"seconds": 4.22,
"usage": {
"completion_tokens": 385,
"prompt_tokens": 335,
"total_tokens": 720,
"prompt_tokens_details": {
"cached_tokens": 5
}
},
"finish_reason": "stop"
},
{
"step": "windows-coder",
"model": "Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf",
"seconds": 3.15,
"usage": {
"completion_tokens": 348,
"prompt_tokens": 173,
"total_tokens": 521,
"prompt_tokens_details": {
"cached_tokens": 3
}
},
"finish_reason": "stop"
},
{
"step": "windows-review",
"model": "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf",
"seconds": 3.52,
"usage": {
"completion_tokens": 296,
"prompt_tokens": 378,
"total_tokens": 674,
"prompt_tokens_details": {
"cached_tokens": 3
}
},
"finish_reason": "stop"
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

View file

@ -263,12 +263,40 @@ namespace HermesHubSetup
return true;
}
// Restrict cleanup to this installation. Never kill arbitrary Python/browser processes.
public static void StopOwnedRuntime(string home, bool includeLauncher)
{
string escaped = Path.GetFullPath(home).TrimEnd('\\').Replace("'", "''");
string script = "$ErrorActionPreference='Stop'; $root='" + escaped + "'; " +
"$py=@((Join-Path $root 'hermes-agent\\venv\\Scripts\\python.exe'),(Join-Path $root 'hermes-agent\\venv\\Scripts\\pythonw.exe')); " +
"$all=@(Get-CimInstance Win32_Process); $protected=@($PID); $cursor=$PID; " +
"while ($cursor) { $node=$all | Where-Object ProcessId -eq $cursor | Select-Object -First 1; if (!$node) { break }; $cursor=$node.ParentProcessId; if ($cursor -in $protected) { break }; $protected+= $cursor }; " +
"function Stop-HubBranch([int]$processId) { foreach ($child in @($all | Where-Object ParentProcessId -eq $processId)) { if ($child.ProcessId -notin $protected) { Stop-HubBranch $child.ProcessId } }; " +
"if (Get-Process -Id $processId -ErrorAction SilentlyContinue) { Stop-Process -Id $processId -Force -ErrorAction Stop } }; " +
"$targets=@($all | Where-Object { " +
"($_.ExecutablePath -in $py -and ($_.CommandLine -match 'hermes_hub_web_entry\\.py|antigravity_provider\\.router\\.web'))" +
" -or ($_.Name -in @('msedge.exe','chrome.exe','chromium.exe') -and $_.CommandLine -match ('--user-data-dir=[\\x22]?'+[regex]::Escape((Join-Path $root 'web_browser_profile'))+'[\\x22]?(?:\\s|$)'))" +
(includeLauncher ? " -or ($_.Name -eq 'HermesHubWeb.exe' -and ($_.ExecutablePath -eq (Join-Path $root 'HermesHubWeb.exe') -or $_.ExecutablePath -eq (Join-Path $env:LOCALAPPDATA 'Programs\\HermesHub\\HermesHubWeb.exe')))" : "") +
" }); foreach ($target in $targets) { Stop-HubBranch $target.ProcessId; " +
"if (Get-Process -Id $target.ProcessId -ErrorAction SilentlyContinue) { throw 'Не удалось остановить прежний процесс Hermes Hub' } }";
ProcessStartInfo info = new ProcessStartInfo("powershell.exe", "-NoProfile -NonInteractive -EncodedCommand " + Convert.ToBase64String(Encoding.Unicode.GetBytes(script)));
info.UseShellExecute = false;
info.CreateNoWindow = true;
info.WindowStyle = ProcessWindowStyle.Hidden;
using (Process process = Process.Start(info))
{
if (!process.WaitForExit(20000)) { process.Kill(); throw new IOException("Остановка прежнего сервера превысила 20 секунд"); }
if (process.ExitCode != 0) throw new IOException("Не удалось остановить прежний сервер. Обновление отменено.");
}
}
public static int PerformInstall(string sourceRoot, Action<string, int> progressCallback = null)
{
if (!IsHermesFound) return 10;
try
{
StopOwnedRuntime(HermesHome, true);
if (progressCallback != null) progressCallback("Preparing installation directory...", 10);
if (!Directory.Exists(TargetInstallDir))
{
@ -1074,12 +1102,14 @@ namespace HermesHubSetup
Application.SetCompatibleTextRenderingDefault(false);
bool isSilent = false;
bool restartAfterInstall = false;
bool isUninstall = false;
bool isRepair = false;
bool purgeUserData = false;
foreach (string a in args)
{
if (a.Equals("/restart", StringComparison.OrdinalIgnoreCase)) restartAfterInstall = true;
if (a.Equals("/silent", StringComparison.OrdinalIgnoreCase) || a.Equals("/s", StringComparison.OrdinalIgnoreCase) || a.Equals("-s", StringComparison.OrdinalIgnoreCase)) isSilent = true;
if (a.Equals("/uninstall", StringComparison.OrdinalIgnoreCase) || a.Equals("/u", StringComparison.OrdinalIgnoreCase)) isUninstall = true;
if (a.Equals("/repair", StringComparison.OrdinalIgnoreCase) || a.Equals("/r", StringComparison.OrdinalIgnoreCase) || a.Equals("/reinstall", StringComparison.OrdinalIgnoreCase)) isRepair = true;
@ -1149,6 +1179,11 @@ namespace HermesHubSetup
}
int code = SetupEngine.PerformInstall(sourceRoot);
if (code == 0 && restartAfterInstall)
{
string launcher = Path.Combine(SetupEngine.TargetInstallDir, "HermesHubWeb.exe");
if (File.Exists(launcher)) Process.Start(launcher);
}
Console.WriteLine("Silent install result: " + code);
return code;
}

View file

@ -11,6 +11,8 @@ namespace HermesHub
{
public static class WebLauncher
{
private static Mutex instanceMutex;
[STAThread]
public static void Main(string[] args)
{
@ -69,9 +71,17 @@ namespace HermesHub
string targetUrl = string.Format("http://{0}:{1}/", host, port);
string healthUrl = string.Format("http://{0}:{1}/api/health", host, port);
bool firstInstance;
instanceMutex = new Mutex(true, "Local\\HermesHubWeb", out firstInstance);
if (!firstInstance) { Process.Start(targetUrl); return; }
// Adopt no unknown server: stop only a verified process from our installation.
try { StopOwnedRuntime(hermesHome, false); }
catch (Exception ex) { MessageBox.Show(ex.Message, "Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Error); return; }
// 2. Check if server is already running and healthy
bool serverWasAlreadyRunning = IsServerHealthy(healthUrl);
Process serverProcess = null;
StringBuilder serverLog = new StringBuilder();
if (!serverWasAlreadyRunning)
{
@ -131,6 +141,14 @@ namespace HermesHub
try
{
serverProcess = Process.Start(serverPsi);
DataReceivedEventHandler collect = delegate(object sender, DataReceivedEventArgs item) {
if (item.Data == null) return;
lock (serverLog) { serverLog.AppendLine(item.Data); if (serverLog.Length > 4000) serverLog.Remove(0, serverLog.Length - 4000); }
};
serverProcess.ErrorDataReceived += collect;
serverProcess.OutputDataReceived += collect;
serverProcess.BeginErrorReadLine();
serverProcess.BeginOutputReadLine();
}
catch (Exception ex)
{
@ -149,12 +167,8 @@ namespace HermesHub
}
if (serverProcess.HasExited)
{
string why = "";
try { why = serverProcess.StandardError.ReadToEnd(); } catch { }
if (string.IsNullOrEmpty(why))
{
try { why = serverProcess.StandardOutput.ReadToEnd(); } catch { }
}
string why;
lock (serverLog) { why = serverLog.ToString(); }
if (why.Length > 1500) why = why.Substring(why.Length - 1500);
string msg = "Веб-сервер Hermes Hub завершился с ошибкой.";
if (!string.IsNullOrEmpty(why)) msg += Environment.NewLine + Environment.NewLine + why.Trim();
@ -178,7 +192,7 @@ namespace HermesHub
// 4. Locate browser in strict priority: Edge -> Chrome -> Chromium registry -> Fallback
string browserPath = FindChromiumBrowser();
Process browserProc = null;
DateTime browserStartedAt = DateTime.UtcNow;
if (!string.IsNullOrEmpty(browserPath))
{
@ -199,7 +213,7 @@ namespace HermesHub
try
{
browserProc = Process.Start(browserPsi);
browserStartedAt = DateTime.UtcNow;
}
catch (Exception ex)
{
@ -226,36 +240,113 @@ namespace HermesHub
}
}
// 5. Server Lifecycle:
// If the server was started by this launcher session and browser is tracked,
// wait for browser window to close, then gracefully terminate server process.
if (!serverWasAlreadyRunning && serverProcess != null && !serverProcess.HasExited && browserProc != null)
Application.Run(new HubContext(hermesHome, targetUrl, browserPath, browserProc));
instanceMutex.ReleaseMutex();
}
// Restrict cleanup to this installation. Never kill arbitrary Python/browser processes.
public static void StopOwnedRuntime(string home, bool includeLauncher)
{
string escaped = Path.GetFullPath(home).TrimEnd('\\').Replace("'", "''");
string script = "$ErrorActionPreference='Stop'; $root='" + escaped + "'; " +
"$py=@((Join-Path $root 'hermes-agent\\venv\\Scripts\\python.exe'),(Join-Path $root 'hermes-agent\\venv\\Scripts\\pythonw.exe')); " +
"$all=@(Get-CimInstance Win32_Process); $protected=@($PID); $cursor=$PID; " +
"while ($cursor) { $node=$all | Where-Object ProcessId -eq $cursor | Select-Object -First 1; if (!$node) { break }; $cursor=$node.ParentProcessId; if ($cursor -in $protected) { break }; $protected+= $cursor }; " +
"function Stop-HubBranch([int]$processId) { foreach ($child in @($all | Where-Object ParentProcessId -eq $processId)) { if ($child.ProcessId -notin $protected) { Stop-HubBranch $child.ProcessId } }; " +
"if (Get-Process -Id $processId -ErrorAction SilentlyContinue) { Stop-Process -Id $processId -Force -ErrorAction Stop } }; " +
"$targets=@($all | Where-Object { " +
"($_.ExecutablePath -in $py -and ($_.CommandLine -match 'hermes_hub_web_entry\\.py|antigravity_provider\\.router\\.web'))" +
" -or ($_.Name -in @('msedge.exe','chrome.exe','chromium.exe') -and $_.CommandLine -match ('--user-data-dir=[\\x22]?'+[regex]::Escape((Join-Path $root 'web_browser_profile'))+'[\\x22]?(?:\\s|$)'))" +
(includeLauncher ? " -or ($_.Name -eq 'HermesHubWeb.exe' -and ($_.ExecutablePath -eq (Join-Path $root 'HermesHubWeb.exe') -or $_.ExecutablePath -eq (Join-Path $env:LOCALAPPDATA 'Programs\\HermesHub\\HermesHubWeb.exe')))" : "") +
" }); foreach ($target in $targets) { Stop-HubBranch $target.ProcessId; " +
"if (Get-Process -Id $target.ProcessId -ErrorAction SilentlyContinue) { throw 'Не удалось остановить прежний процесс Hermes Hub' } }";
ProcessStartInfo info = new ProcessStartInfo("powershell.exe", "-NoProfile -NonInteractive -EncodedCommand " + Convert.ToBase64String(Encoding.Unicode.GetBytes(script)));
info.UseShellExecute = false;
info.CreateNoWindow = true;
info.WindowStyle = ProcessWindowStyle.Hidden;
using (Process process = Process.Start(info))
{
try
{
browserProc.WaitForExit();
}
catch { }
// Подстраховка: если процесс браузера завершился почти сразу,
// это почти наверняка передача окна другому экземпляру, а не
// закрытие пользователем. Убивать сервер в этом случае нельзя.
if (DateTime.UtcNow - browserStartedAt < TimeSpan.FromSeconds(5))
{
return;
}
try
{
if (!serverProcess.HasExited)
{
serverProcess.Kill();
}
}
catch { }
if (!process.WaitForExit(20000)) { process.Kill(); throw new IOException("Остановка прежнего сервера превысила 20 секунд"); }
if (process.ExitCode != 0) throw new IOException("Не удалось остановить прежний сервер. Обновление отменено.");
}
}
private sealed class HubContext : ApplicationContext
{
private readonly NotifyIcon tray;
private readonly System.Windows.Forms.Timer timer;
private readonly string home, url, browserPath;
private Process browser;
private bool watching, hadWindow, closing;
public HubContext(string homePath, string targetUrl, string browserExe, Process browserProcess)
{
home = homePath; url = targetUrl; browserPath = browserExe; browser = browserProcess;
watching = browser != null;
tray = new NotifyIcon();
tray.Icon = System.Drawing.SystemIcons.Application;
tray.Text = "Hermes Hub — работает в фоне";
ContextMenuStrip menu = new ContextMenuStrip();
menu.Items.Add("Открыть", null, delegate { Open(); });
menu.Items.Add("Выход", null, delegate { ExitCompletely(); });
tray.ContextMenuStrip = menu;
tray.DoubleClick += delegate { Open(); };
tray.Visible = true;
timer = new System.Windows.Forms.Timer(); timer.Interval = 500;
timer.Tick += delegate { WatchWindow(); }; timer.Start();
}
private void WatchWindow()
{
if (!watching || closing || browser == null) return;
bool closed;
try {
browser.Refresh();
if (!browser.HasExited && browser.MainWindowHandle != IntPtr.Zero) hadWindow = true;
closed = browser.HasExited || (hadWindow && browser.MainWindowHandle == IntPtr.Zero);
} catch { closed = true; }
if (!closed) return;
watching = false;
DialogResult answer = MessageBox.Show("Закрыть Hermes Hub полностью?\n\nДа — остановить сервер и фоновые опросы.\nНет — оставить в фоне (значок в области уведомлений).", "Hermes Hub", MessageBoxButtons.YesNo, MessageBoxIcon.Question);
if (answer == DialogResult.Yes) ExitCompletely();
}
private void Open()
{
if (closing) return;
try {
if (browser != null && !browser.HasExited && browser.MainWindowHandle != IntPtr.Zero) {
ShowWindow(browser.MainWindowHandle, 9); SetForegroundWindow(browser.MainWindowHandle); return;
}
if (string.IsNullOrEmpty(browserPath)) { Process.Start(url); return; }
ProcessStartInfo info = new ProcessStartInfo(browserPath,
"--app=\"" + url + "\" --window-size=1400,900 --user-data-dir=\"" + Path.Combine(home, "web_browser_profile") + "\" --no-first-run --no-default-browser-check");
info.UseShellExecute = false;
browser = Process.Start(info); watching = true; hadWindow = false;
} catch (Exception ex) { MessageBox.Show(ex.Message, "Hermes Hub"); }
}
private void ExitCompletely()
{
if (closing) return;
closing = true; timer.Stop();
try {
if (browser != null && !browser.HasExited) {
ProcessStartInfo kill = new ProcessStartInfo("taskkill.exe", "/PID " + browser.Id + " /T /F");
kill.UseShellExecute = false; kill.CreateNoWindow = true;
using (Process process = Process.Start(kill)) { process.WaitForExit(5000); }
}
StopOwnedRuntime(home, false);
tray.Visible = false; tray.Dispose(); timer.Dispose(); ExitThread();
} catch (Exception ex) {
closing = false; timer.Start();
MessageBox.Show("Не удалось завершить всё: " + ex.Message, "Hermes Hub", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
[System.Runtime.InteropServices.DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr handle);
[System.Runtime.InteropServices.DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr handle, int command);
}
public static bool IsServerHealthy(string url)
{
try

View file

@ -50,6 +50,38 @@ if ([string]::IsNullOrWhiteSpace($TargetDir)) {
Write-Host "[2/6] Preparing installation target: $TargetDir" -ForegroundColor Yellow
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
# Stop only processes owned by this installation before replacing files.
# Preserve our own ancestor branch: an updater may have launched this installer.
$hubProcesses = @(Get-CimInstance Win32_Process)
$hubProtected = @($PID)
$hubCursor = $PID
while ($hubCursor) {
$hubNode = $hubProcesses | Where-Object ProcessId -eq $hubCursor | Select-Object -First 1
if (-not $hubNode) { break }
$hubCursor = $hubNode.ParentProcessId
if ($hubCursor -in $hubProtected) { break }
$hubProtected += $hubCursor
}
function Stop-HubBranch([int]$ProcessId) {
foreach ($child in @($hubProcesses | Where-Object ParentProcessId -eq $ProcessId)) {
if ($child.ProcessId -notin $hubProtected) { Stop-HubBranch $child.ProcessId }
}
if (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue) {
Stop-Process -Id $ProcessId -Force -ErrorAction Stop
}
}
$hubPythonPaths = @($HermesPython, (Join-Path $HermesHome 'hermes-agent\venv\Scripts\pythonw.exe'))
$hubLauncherPaths = @((Join-Path $HermesHome 'HermesHubWeb.exe'), (Join-Path $TargetDir 'HermesHubWeb.exe'))
$hubBrowserPattern = '--user-data-dir="?' + [regex]::Escape((Join-Path $HermesHome 'web_browser_profile')) + '"?(?:\s|$)'
foreach ($hubProcess in $hubProcesses) {
if (($hubProcess.ExecutablePath -in $hubPythonPaths -and $hubProcess.CommandLine -match 'hermes_hub_web_entry\.py|antigravity_provider\.router\.web') -or
($hubProcess.ExecutablePath -in $hubLauncherPaths) -or
($hubProcess.Name -in @('msedge.exe','chrome.exe','chromium.exe') -and $hubProcess.CommandLine -match $hubBrowserPattern)) {
Stop-HubBranch $hubProcess.ProcessId
if (Get-Process -Id $hubProcess.ProcessId -ErrorAction SilentlyContinue) { throw 'Old Hermes Hub process survived. Installation cancelled.' }
}
}
# 4. Copy Application Files to TargetDir
$RepoRoot = Split-Path -Parent $PSScriptRoot
Write-Host "[3/6] Deploying application binaries..." -ForegroundColor Yellow

View file

@ -113,7 +113,7 @@ def discover_models(profile_id: str | None = None) -> dict[str, str]:
Also populates :data:`_AGY_EFFORT_MAP` with supported efforts per model.
"""
global _AGY_MODEL_CACHE, _AGY_EFFORT_MAP
if _AGY_MODEL_CACHE is not None:
if profile_id is None and _AGY_MODEL_CACHE:
return dict(_AGY_MODEL_CACHE)
exe = get_agy_exe()
@ -166,23 +166,13 @@ def discover_models(profile_id: str | None = None) -> dict[str, str]:
)
raw = result.stdout.strip()
if not raw or result.returncode != 0:
logger.warning("discover_models: agy models failed or returned empty output (rc=%s)", result.returncode)
if _AGY_MODEL_CACHE is None:
_AGY_MODEL_CACHE = {}
_AGY_EFFORT_MAP = {}
return dict(_AGY_MODEL_CACHE)
except subprocess.TimeoutExpired:
logger.warning("discover_models: agy models timed out")
if _AGY_MODEL_CACHE is None:
_AGY_MODEL_CACHE = {}
_AGY_EFFORT_MAP = {}
return dict(_AGY_MODEL_CACHE)
except Exception as exc:
logger.warning("discover_models failed: %s", exc)
if _AGY_MODEL_CACHE is None:
_AGY_MODEL_CACHE = {}
_AGY_EFFORT_MAP = {}
return dict(_AGY_MODEL_CACHE)
if profile_id:
raise RuntimeError(f"agy models: код {result.returncode}; каталог не получен")
return dict(_AGY_MODEL_CACHE or {})
except Exception:
if profile_id:
raise
return dict(_AGY_MODEL_CACHE or {})
models: dict[str, str] = {}
effort_map: dict[str, set[str]] = {}
@ -223,52 +213,6 @@ def discover_models(profile_id: str | None = None) -> dict[str, str]:
return dict(models)
def launch_native_agy_login(profile_id: str) -> subprocess.Popen:
"""Launch agy CLI in a visible interactive terminal window with isolated profile environment.
A22 Requirement: Native login executed by agy itself within the target profile's isolated directory.
Zero interception, zero credential logging.
"""
from antigravity_provider.router.adapters.antigravity_adapter import get_profile_env_dir
profile_dir = get_profile_env_dir(profile_id)
profile_dir.mkdir(parents=True, exist_ok=True)
gemini_dir = profile_dir / ".gemini"
gemini_dir.mkdir(parents=True, exist_ok=True)
exe = get_agy_exe()
env = build_safe_subprocess_env(
overrides={
"USERPROFILE": str(profile_dir),
"HOME": str(profile_dir),
"HOMEPATH": str(profile_dir),
"HOMEDRIVE": str(profile_dir)[:2] if str(profile_dir)[1:2] == ":" else "",
}
)
if os.name == "nt":
# Launch visible console window on Windows
return subprocess.Popen(
[exe],
env=env,
creationflags=subprocess.CREATE_NEW_CONSOLE,
)
else:
# Cross-platform fallback (Linux/macOS)
import shutil
terminals = [
["x-terminal-emulator", "-e", exe],
["gnome-terminal", "--", exe],
["xterm", "-e", exe],
["konsole", "-e", exe],
]
for term_cmd in terminals:
if shutil.which(term_cmd[0]):
return subprocess.Popen(term_cmd, env=env)
return subprocess.Popen([exe], env=env)
def check_profile_native_auth_status(profile_id: str) -> tuple[bool, str | None, dict[str, Any] | None]:
"""Check if agy native authentication has completed in profile's directory.

View file

@ -14,6 +14,11 @@ class AccountProbeService:
def __init__(self) -> None:
self.enabled = False
self._next_check = 0.0
self.last_tick = None
self.error = None
self._profile_locks = {}
self._closed = False
self._cloud_next = 0.0
self._states: dict[str, dict[str, Any]] = {}
self._lock = threading.Lock()
self._pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="account-probe")
@ -29,66 +34,134 @@ class AccountProbeService:
with self._lock:
return dict(self._states.get(profile_id, {"state": "never_checked"}))
def schedule(self, provider: str, profile_id: str, *, force: bool = False) -> bool:
if not self.enabled:
return False
def _profile_lock(self, profile_id: str):
with self._lock:
return self._profile_locks.setdefault(profile_id, threading.Lock())
def _mark_checking(self, provider, profile_id):
with self._lock:
current = self._states.get(profile_id, {})
if current.get("state") == "checking":
return False
if not force and current.get("checked_at") and time.time() - current["checked_at"] < 30:
return False
self._states[profile_id] = {
**current, "state": "checking", "provider": provider,
"started_at": time.time(), "message": "Идёт опрос провайдера — это может занять до минуты",
**self._states.get(profile_id, {}), "state": "checking",
"provider": provider, "started_at": time.time(),
"message": "Идёт запрос к провайдеру",
}
self._pool.submit(self._run, provider, profile_id)
def schedule(self, provider: str, profile_id: str, *, force: bool = False) -> bool:
if not self.enabled or self._closed:
return False
lock = self._profile_lock(profile_id)
if not lock.acquire(blocking=False):
return False
current = self.state(profile_id)
if not force and time.time() - current.get("checked_at", 0) < 30:
lock.release()
return False
self._mark_checking(provider, profile_id)
try:
future = self._pool.submit(self._run, provider, profile_id)
def cancelled(done):
if done.cancelled():
with self._lock:
self._states[profile_id] = {"state": "failed", "message": "Проверка отменена при завершении сервера"}
lock.release()
future.add_done_callback(cancelled)
except Exception:
with self._lock:
self._states[profile_id] = current
lock.release()
raise
return True
def check_now(self, provider: str, profile_id: str, models_only: bool = False) -> dict:
# Periodic scheduling can be disabled without disabling a manual request.
if self._closed:
return {"ok": False, "message": "Сервер завершает работу"}
lock = self._profile_lock(profile_id)
if not lock.acquire(timeout=90):
return {"ok": False, "message": "Проверка этого аккаунта ещё выполняется; повторите позже"}
try:
if self._closed:
return {"ok": False, "message": "Сервер завершает работу"}
self._mark_checking(provider, profile_id)
return self._probe(provider, profile_id, models_only)
finally:
lock.release()
def record_validation(self, provider: str, profile_id: str, result: dict) -> None:
with self._lock:
self._states[profile_id] = {
"state": "working", "provider": provider, "checked_at": time.time(),
"message": result["message"], "models": result["data"]["models"],
"check_kind": "credentials_and_catalog",
}
def status(self) -> dict:
return {"enabled": self.enabled, "last_tick": self.last_tick, "error": self.error}
def tick(self, now: Optional[float] = None) -> int:
from .settings_service import get_hub_settings
now = time.monotonic() if now is None else now
if not self.enabled or now < self._next_check:
return 0
self._next_check = now + get_hub_settings()["account_check_interval_seconds"]
return self.schedule_all(force=True)
try:
count = self.schedule_all(force=True)
self._next_check = now + get_hub_settings()["account_check_interval_seconds"]
self.last_tick, self.error = time.time(), None
return count
except Exception as exc:
self.error = str(exc).strip() or type(exc).__name__
return 0
def shutdown(self) -> None:
self.enabled, self._closed = False, True
self._pool.shutdown(wait=False, cancel_futures=True)
def schedule_all(self, *, force: bool = False) -> int:
from .profile_manager import ProfileAuthManager
from .router_config import load_router_config
count = 0
has_ollama = False
for pid, pcfg in load_router_config().profiles.items():
if pcfg.enabled and ProfileAuthManager.get_profile_status(pcfg.provider, pid).get("authenticated"):
count += int(self.schedule(pcfg.provider, pid, force=force))
has_ollama |= pcfg.provider == "ollama"
if self.enabled and has_ollama and time.monotonic() >= self._cloud_next:
from .model_discovery_service import ModelDiscoveryService
self._cloud_next = time.monotonic() + 3600
self._pool.submit(ModelDiscoveryService.get().discover_ollama_cloud)
return count
def _run(self, provider: str, profile_id: str) -> None:
try:
self._probe(provider, profile_id)
finally:
self._profile_lock(profile_id).release()
def _probe(self, provider: str, profile_id: str, models_only: bool = False) -> dict:
from .action_handler import do_test_profile
from .model_discovery_service import ModelDiscoveryService
models, meta = None, {}
try:
from .state_store import HubStateStore
HubStateStore.get().refresh(force_scan=True)
models = ModelDiscoveryService.get().discover_models_sync(provider, timeout=20, profile_id=profile_id)
HubStateStore.get().refresh(force_scan=True)
if provider == "ollama":
cloud = ModelDiscoveryService.get().get_models_with_metadata("ollama-cloud-catalog")
if cloud.get("is_stale"):
ModelDiscoveryService.get().discover_ollama_cloud()
result = do_test_profile(provider, profile_id, timeout=60, discovered_models=models)
meta = ModelDiscoveryService.get().get_models_with_metadata(provider, profile_id)
success = bool(result.get("success"))
message = result.get("response") or result.get("error") or "Проверка завершена без пояснения"
discovery = ModelDiscoveryService.get()
models = discovery.discover_models_sync(provider, timeout=65 if provider == "antigravity" else 20, profile_id=profile_id)
meta = discovery.get_models_with_metadata(provider, profile_id)
if models is None and meta.get("error"):
success, message = False, meta["error"]
elif models_only:
success = models is not None and not meta.get("error")
message = meta.get("error") or (f"Получено моделей: {len(models)}" if success else "Провайдер не вернул каталог моделей")
else:
result = do_test_profile(provider, profile_id, timeout=60, discovered_models=models)
success = bool(result.get("success"))
message = result.get("response") or result.get("error") or "Провайдер не сообщил причину результата проверки"
state = "working" if success else "failed"
except Exception as exc:
models, meta, state, message = None, {}, "failed", str(exc)
success, state, message = False, "failed", str(exc).strip() or type(exc).__name__
record = {
"state": state, "provider": provider, "checked_at": time.time(),
"message": message, "models": models, "model_error": meta.get("error"),
"models_discovered_at": meta.get("discovered_at"), "models_only": models_only,
}
with self._lock:
self._states[profile_id] = {
"state": state, "provider": provider, "checked_at": time.time(),
"message": message, "models": models, "model_error": meta.get("error"),
"models_discovered_at": meta.get("discovered_at"),
}
try:
from .state_store import HubStateStore
HubStateStore.get().refresh(force_scan=True)
except Exception:
pass
self._states[profile_id] = record
return {"ok": success, "message": message, "data": record}

View file

@ -18,6 +18,8 @@ from antigravity_provider import paths
from antigravity_provider.router.adapters import get_adapter
logger = logging.getLogger('hermes.router.actions')
_test_locks_guard = threading.Lock()
_test_locks: dict[str, Any] = {}
def do_set_main(provider: str, profile_id: str) -> Tuple[bool, str]:
ok, msg = ProfileAuthManager.set_main_profile(provider, profile_id)
@ -55,7 +57,10 @@ def do_test_profile(provider: str, profile_id: str, timeout: float = 10.0, disco
if status.get('is_expired') or status.get('expired') or status.get('status') == 'EXPIRED':
return {'success': False, 'error': 'Авторизация истекла, требуется повторный вход.'}
model = pcfg.preferred_models[0] if pcfg.preferred_models else (discovered_models or ['default'])[0]
candidates = discovered_models if discovered_models is not None else pcfg.preferred_models
if not candidates:
return {'success': False, 'error': 'Сервер отвечает, но доступных моделей для тестового запроса нет' if discovered_models == [] else 'Каталог моделей не получен; сначала запросите список моделей'}
model = next((model for model in pcfg.preferred_models if model in candidates), candidates[0])
t0 = time.time()
try:
auth_data = ProfileAuthManager.load_profile_auth(pcfg.provider, profile_id)
@ -73,14 +78,25 @@ def do_test_profile(provider: str, profile_id: str, timeout: float = 10.0, disco
result_container = []
error_container = []
with _test_locks_guard:
invoke_lock = _test_locks.setdefault(profile_id, threading.Lock())
if not invoke_lock.acquire(blocking=False):
return {'success': False, 'error': 'Предыдущий запрос этого аккаунта ещё не завершился'}
def _call_invoke():
try:
result_container.append(adapter.invoke(pcfg, req))
except Exception as e:
error_container.append(e)
finally:
invoke_lock.release()
t = threading.Thread(target=_call_invoke, daemon=True)
t.start()
try:
t.start()
except Exception:
invoke_lock.release()
raise
t.join(timeout=timeout)
el = round(time.time() - t0, 2)
@ -110,7 +126,7 @@ def do_test_profile(provider: str, profile_id: str, timeout: float = 10.0, disco
}
except Exception as e:
EventLogService.get().log('system', f'Сбой проверки {profile_id} ({model}): {e}', level='error')
return {'success': False, 'model': model, 'duration_sec': round(time.time() - t0, 2), 'error': str(e)}
return {'success': False, 'model': model, 'duration_sec': round(time.time() - t0, 2), 'error': str(e).strip() or type(e).__name__}
def do_delete_credentials(provider: str, profile_id: str, actor: str = "system") -> Tuple[bool, str]:
# Сигнатура get_profile_dir — (profile_id, provider), а здесь её звали
@ -125,6 +141,8 @@ def do_delete_credentials(provider: str, profile_id: str, actor: str = "system")
if auth_p.is_file():
try:
auth_p.unlink()
from .state_store import HubStateStore
HubStateStore.get().apply_delta_account_removed(provider, profile_id)
EventLogService.get().log(
'account',
f'Учетные данные для {profile_id} удалены.',
@ -281,11 +299,8 @@ def do_set_model(profile_id: str, model: str, role_id: Optional[str] = None) ->
updated.roles[role_id].default_model = model
if save_router_config(updated):
try:
from antigravity_provider.router.state_store import HubStateStore
HubStateStore.get().refresh(force_scan=True)
except Exception:
pass
from .state_store import HubStateStore
HubStateStore.get().apply_delta_profile_preferences(profile_id, target.preferred_models)
EventLogService.get().log(
"model", f"Для профиля {profile_id} ({provider}) установлена модель '{model}'.", level="info"
)
@ -341,15 +356,19 @@ def do_save_request_options(profile_id: str, request_options: Any) -> Tuple[bool
# Функция объявлена на уровне модуля намеренно: как вложенная она была видна
# не всем точкам завершения входа, и device-flow получал NameError внутри
# обработки успеха.
def _rescan_after_auth() -> None:
try:
from antigravity_provider.router.state_store import HubStateStore
HubStateStore.get().refresh(force_scan=True)
from .account_probe_service import AccountProbeService
AccountProbeService.get().schedule_all()
except Exception as exc: # пересбор не должен ронять сам вход
logger.warning("Не удалось пересобрать снапшот после входа: %s", exc)
def _rescan_after_auth(provider=None, profile_id=None) -> None:
def refresh():
try:
from .state_store import HubStateStore
from .account_probe_service import AccountProbeService
if provider and profile_id:
HubStateStore.get().apply_delta_account_added(provider, profile_id)
else:
HubStateStore.get().refresh(force_scan=True)
AccountProbeService.get().schedule_all()
except Exception as exc:
logger.warning("Не удалось обновить состояние после входа: %s", exc)
threading.Thread(target=refresh, name="auth-state-refresh", daemon=True).start()
def generate_quotas_export(format: str = "json") -> Any:
@ -686,6 +705,10 @@ class ActionExecutor:
return {'ok': False, 'message': reason, 'data': {'status': status}}
return {'ok': True, 'message': 'Ожидание подтверждения', 'data': {'status': status}}
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 '')
# Подключение аккаунта: сохранение профиля и учетных данных (P0-1)
if action == 'add_account':
prov_norm = (prov or data.get('provider') or '').strip().lower()
@ -712,6 +735,17 @@ class ActionExecutor:
token = (data.get('token') or data.get('api_key') or '').strip()
slot = data.get('profile_id')
if slot:
valid, reason = AutoAssigner.validate_slot(prov_norm, slot)
if not valid:
return {'ok': False, 'message': reason}
validation = None
if token or prov_norm in ('local', 'vllm', 'ollama'):
from .connection_preflight import validate_connection
validation = validate_connection(prov_norm, token, base_url, data.get('preferred_model') or '')
if not validation['ok']:
return validation
base_url = validation['data']['base_url']
slot = slot or AutoAssigner.find_free_slot(prov_norm) or f'{prov_norm}-1'
status = ProfileAuthManager.get_profile_status(prov_norm, slot)
@ -789,11 +823,21 @@ class ActionExecutor:
# авторизованного аккаунта не существует: перевод аккаунта в
# другую роль падал с ошибкой, хотя ключ вводить не требуется.
AutoAssigner.assign_profile_to_role(slot, target_role, is_primary=False)
_rescan_after_auth()
if validation:
from .model_discovery_service import ModelDiscoveryService
ModelDiscoveryService.get().remember_models(prov_norm, slot, validation['data']['models'])
if data.get('preferred_model'):
ok, message = do_set_model(slot, data['preferred_model'])
if not ok:
return {'ok': False, 'message': message}
_rescan_after_auth(prov_norm, slot)
from antigravity_provider.router.account_probe_service import AccountProbeService
AccountProbeService.get().schedule(prov_norm, slot, force=True)
check_note = 'проверка запускается в фоне' if AccountProbeService.get().enabled else 'проверка Н/Д: фоновая служба не запущена'
return {'ok': True, 'message': f'Аккаунт {prov_norm} ({slot}) сохранён; {check_note}', 'data': {'profile_id': slot}}
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)
result.setdefault('data', {})['profile_id'] = slot
return result
except Exception as e:
return {'ok': False, 'message': f'Ошибка при сохранении учетных данных {slot}: {e}'}
@ -942,6 +986,29 @@ class ActionExecutor:
res = do_test_profile(prov, pid)
return {'ok': res.get('success', False), 'message': res.get('response') or res.get('error'), 'data': res}
elif action == 'clear_accounts':
from .profile_manager import get_profile_auth_path
protected_root = (paths.get_hermes_home() / 'agy_profiles').resolve()
targets, protected = [], []
for profile_id, profile in load_router_config().profiles.items():
auth_path = get_profile_auth_path(profile.provider, profile_id)
if profile.provider in ('antigravity', 'google-antigravity', 'agy') or auth_path.is_symlink() or auth_path.resolve().is_relative_to(protected_root):
protected.append(profile_id)
elif auth_path.is_file():
targets.append({'provider': profile.provider, 'profile_id': profile_id})
preview = {'targets': targets, 'protected': protected}
if not data.get('confirmed'):
return {'ok': True, 'message': f'Будут удалены ключи {len(targets)} аккаунтов. Antigravity исключён из очистки.', 'data': preview}
# Require the exact displayed list. A newly added account is never silently deleted.
if data.get('targets') != targets:
return {'ok': False, 'message': 'Список аккаунтов изменился. Повторите предварительный просмотр.', 'data': preview}
errors = []
for target in targets:
ok, message = do_delete_credentials(target['provider'], target['profile_id'], actor=actor)
if not ok:
errors.append(message)
return {'ok': not errors, 'message': '; '.join(errors) if errors else f'Удалены ключи {len(targets)} аккаунтов. Antigravity сохранён.', 'data': preview}
elif action == 'delete_credentials':
dry_run = bool(data.get('dry_run', False))
confirmed = bool(data.get('confirmed', True))
@ -1042,13 +1109,10 @@ class ActionExecutor:
elif action == 'check_account':
from antigravity_provider.router.account_probe_service import AccountProbeService
if not AccountProbeService.get().enabled:
return {"ok": False, "message": "Фоновая служба проверки не запущена. Перезапустите веб-сервер."}
valid, reason = AutoAssigner.validate_slot(prov, pid)
if not valid:
return {'ok': False, 'message': reason}
started = AccountProbeService.get().schedule(prov, pid, force=True)
return {'ok': True, 'message': 'Проверка запущена' if started else 'Проверка уже выполняется'}
return {'ok': False, 'message': reason or 'Неверный профиль'}
return AccountProbeService.get().check_now(prov, pid)
elif action == 'check_all_accounts':
from antigravity_provider.router.account_probe_service import AccountProbeService
@ -1074,6 +1138,12 @@ class ActionExecutor:
return {'ok': True, 'message': 'Успешно'}
elif action == 'refresh_models':
if pid:
from .account_probe_service import AccountProbeService
valid, reason = AutoAssigner.validate_slot(prov, pid)
if not valid:
return {'ok': False, 'message': reason or 'Неверный профиль'}
return AccountProbeService.get().check_now(prov, pid, models_only=True)
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
service = ModelDiscoveryService.get()
if prov:

View file

@ -0,0 +1,93 @@
"""Validate supplied credentials before creating any profile or writing auth files."""
import json
import urllib.error
import urllib.request
from urllib.parse import urlsplit
DEFAULT_URLS = {
"openrouter": "https://openrouter.ai/api/v1",
"nvidia": "https://integrate.api.nvidia.com/v1",
"local": "http://127.0.0.1:8081/v1", "vllm": "http://127.0.0.1:8081/v1",
"ollama": "http://127.0.0.1:11434", "claude": "https://api.anthropic.com/v1",
"opencode-go": "https://opencode.ai/zen/go/v1", "grok": "https://api.x.ai/v1",
"openai-codex": "https://api.openai.com/v1",
}
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise ValueError("Перенаправление API запрещено: укажите конечный URL сервера")
def validate_connection(provider, token="", base_url="", preferred_model=""):
provider = {"nvidia-nim": "nvidia", "local-llm": "local", "llama.cpp": "local"}.get(provider, provider)
try:
base_url = (base_url or DEFAULT_URLS.get(provider, "")).rstrip("/")
parsed = urlsplit(base_url)
if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment:
raise ValueError("Укажите HTTP(S) URL сервера без пароля, параметров и фрагмента")
if provider not in DEFAULT_URLS:
raise ValueError("Для этого провайдера используйте вход через авторизацию")
if provider not in ("local", "vllm", "ollama") and not token:
raise ValueError("Не указан API-ключ")
headers = {"Content-Type": "application/json"}
if provider == "claude":
headers.update({"x-api-key": token, "anthropic-version": "2023-06-01"})
elif token:
headers["Authorization"] = "Bearer " + token
opener = urllib.request.build_opener(_NoRedirect())
def request(path, body=None):
req = urllib.request.Request(base_url + path, headers=headers,
data=json.dumps(body).encode() if body is not None else None)
with opener.open(req, timeout=20) as response:
payload = json.load(response)
if not isinstance(payload, dict) or payload.get("error"):
raise ValueError("Провайдер вернул ошибку или неверный JSON")
return payload
if provider == "openrouter":
# The catalog is public: its HTTP 200 is not proof of a valid key.
request("/key")
if provider == "ollama":
base_url = base_url.removesuffix("/v1")
payload, field, key = request("/api/tags"), "models", "name"
else:
payload, field, key = request("/models"), "data", "id"
entries = payload.get(field)
if not isinstance(entries, list):
raise ValueError("Провайдер не вернул список моделей")
models = sorted({m[key] for m in entries if isinstance(m, dict) and isinstance(m.get(key), str) and m[key]})
if provider == "nvidia":
if not models:
raise ValueError("Каталог NVIDIA пуст: проверить ключ тестовым запросом невозможно")
if preferred_model and preferred_model not in models:
raise ValueError("Выбранной модели нет в каталоге NVIDIA")
# NVIDIA also exposes a public catalog. Validate with a real request.
chat_models = [model for model in models if any(word in model.lower() for word in ('instruct', 'chat')) and not any(word in model.lower() for word in ('embed', 'guard', 'reward'))]
if not preferred_model and not chat_models:
raise ValueError("Ключ пока не проверен: в каталоге NVIDIA не найдена чат-модель для теста")
result = request("/chat/completions", {"model": preferred_model or chat_models[0],
"messages": [{"role": "user", "content": "ping"}], "max_tokens": 1})
if not isinstance(result.get("choices"), list) or not result["choices"]:
raise ValueError("NVIDIA не вернула результат тестового запроса")
message = f"Подключено и проверено. Получено моделей: {len(models)}" if models else "Сервер отвечает; моделей пока нет"
return {"ok": True, "message": message, "data": {"models": models, "base_url": base_url}}
except Exception as exc:
if isinstance(exc, urllib.error.HTTPError):
reason = exc.reason or 'провайдер отклонил запрос'
try:
body = json.loads(exc.read(4096).decode('utf-8', errors='replace'))
detail = body.get('error') or body.get('detail')
if isinstance(detail, dict):
detail = detail.get('message')
if isinstance(detail, str) and detail.strip():
reason = detail[:500]
except Exception:
pass
message = f"HTTP {exc.code}: {reason}"
else:
message = str(exc).strip() or type(exc).__name__
if token:
message = message.replace(token, "[скрыто]")
return {"ok": False, "message": message, "data": {"models": []}}

View file

@ -76,6 +76,13 @@ class ModelDiscoveryService:
# NON-BLOCKING READ API
# ─────────────────────────────────────────────────────────────
def remember_models(self, provider: str, profile_id: str, models: list[str]) -> None:
with self._cache_lock:
self._cache[f"{provider.lower()}:{profile_id}"] = {
"models": list(models), "discovered_at": time.time(), "error": None,
}
self._save_cache_to_disk()
def get_models(self, provider: str) -> Optional[List[str]]:
"""Return cached models for provider immediately, or None if undiscovered."""
meta = self.get_models_with_metadata(provider)
@ -203,7 +210,7 @@ class ModelDiscoveryService:
result_holder[0] = models
error_holder[0] = err_msg
except Exception as exc:
error_holder[0] = str(exc)
error_holder[0] = str(exc).strip() or type(exc).__name__
worker = threading.Thread(target=_do_probe, daemon=True)
worker.start()
@ -412,7 +419,7 @@ class ModelDiscoveryService:
last_err = self._extract_http_error(http_err)
logger.debug("Codex model query HTTP error on %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
last_err = str(exc).strip() or type(exc).__name__
logger.debug("Codex model query failed on %s: %s", pid, exc)
return None, last_err or "Отсутствуют учетные данные для OpenAI Codex"
@ -448,7 +455,7 @@ class ModelDiscoveryService:
last_err = self._extract_http_error(http_err)
logger.debug("OpenCode model query HTTP error on %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
last_err = str(exc).strip() or type(exc).__name__
logger.debug("OpenCode model query failed on %s: %s", pid, exc)
return None, last_err or "Отсутствуют учетные данные для OpenCode Go"
@ -484,7 +491,7 @@ class ModelDiscoveryService:
last_err = self._extract_http_error(http_err)
logger.debug("Grok model discovery HTTP error for %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
last_err = str(exc).strip() or type(exc).__name__
logger.debug("Grok model discovery failed for %s: %s", pid, exc)
return None, last_err or "Отсутствуют учетные данные для Grok"
@ -523,7 +530,7 @@ class ModelDiscoveryService:
except urllib.error.HTTPError as http_err:
last_err = self._extract_http_error(http_err)
except Exception as exc:
last_err = str(exc)
last_err = str(exc).strip() or type(exc).__name__
return None, last_err or "Отсутствуют учетные данные для Claude"
elif prov in ("openrouter",):
@ -574,7 +581,7 @@ class ModelDiscoveryService:
last_err = self._extract_http_error(http_err)
logger.debug("OpenRouter model discovery HTTP error for %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
last_err = str(exc).strip() or type(exc).__name__
logger.debug("OpenRouter model discovery failed for %s: %s", pid, exc)
return None, last_err or "Отсутствуют учетные данные для OpenRouter"
@ -613,7 +620,7 @@ class ModelDiscoveryService:
last_err = self._extract_http_error(http_err)
logger.debug("NVIDIA model discovery HTTP error for %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
last_err = str(exc).strip() or type(exc).__name__
logger.debug("NVIDIA model discovery failed for %s: %s", pid, exc)
return None, last_err or "Отсутствуют учетные данные для NVIDIA NIM"
@ -655,7 +662,7 @@ class ModelDiscoveryService:
last_err = self._extract_http_error(http_err)
logger.debug("Ollama /api/tags HTTP error on %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
last_err = str(exc).strip() or type(exc).__name__
logger.debug("Ollama /api/tags query failed on %s: %s", pid, exc)
# 2. Try OpenAI-compatible endpoint /v1/models
@ -675,7 +682,7 @@ class ModelDiscoveryService:
last_err = self._extract_http_error(http_err)
logger.debug("Ollama /v1/models HTTP error on %s: %s", pid, last_err)
except Exception as exc:
last_err = str(exc)
last_err = str(exc).strip() or type(exc).__name__
logger.debug("Ollama /v1/models query failed on %s: %s", pid, exc)
return None, last_err or "Не удалось подключиться к серверу Ollama"
@ -708,13 +715,12 @@ class ModelDiscoveryService:
for m in items
if m
]
if models:
return sorted(set(models)), None
return sorted(set(models)), None
except urllib.error.HTTPError as http_err:
last_err = self._extract_http_error(http_err)
logger.debug("Local LLM model query HTTP error on %s (%s): %s", pid, base_url, last_err)
except Exception as exc:
last_err = str(exc)
last_err = str(exc).strip() or type(exc).__name__
logger.debug("Local LLM model query failed on %s (%s): %s", pid, base_url, exc)
return None, last_err or "Не удалось подключиться к локальному серверу LLM"

View file

@ -75,6 +75,7 @@ class HubStateStore:
def __init__(self) -> None:
self._lock = threading.RLock()
self._removed_accounts: set[str] = set()
self._generation: int = 0
self._current_snapshot: Optional[HubSnapshot] = None
self._pending_refreshes: Dict[str, float] = {}
@ -138,6 +139,9 @@ class HubStateStore:
# requests may complete while this build is running.
uh_service = UnifiedHealthService.get()
profiles_by_prov = uh_service.scan_all(force=force_scan)
with self._lock:
removed = set(self._removed_accounts)
profiles_by_prov = {provider: [p for p in profiles if p.profile_id not in removed] for provider, profiles in profiles_by_prov.items()}
all_profs = {
profile.profile_id: profile
for profiles in profiles_by_prov.values()
@ -386,6 +390,8 @@ class HubStateStore:
profile: ProfileViewModel | str,
profile_id: Optional[str] = None,
) -> None:
with self._lock:
self._removed_accounts.discard(profile_id if isinstance(profile, str) else profile.profile_id)
if isinstance(profile, str):
provider = profile
if profile_id is None:
@ -410,8 +416,16 @@ class HubStateStore:
},
)
def apply_delta_profile_preferences(self, profile_id: str, models: list[str]) -> None:
# A configuration change does not require quota/identity network requests.
with self._lock:
profile = self._current_snapshot.get_profile(profile_id) if self._current_snapshot else None
if profile:
self._apply_profile_delta(replace(profile, preferred_models=list(models)))
def apply_delta_account_removed(self, provider: str, profile_id: str) -> None:
with self._lock:
self._removed_accounts.add(profile_id)
current = self._current_snapshot or self._build_empty_snapshot()
all_profiles = dict(current.all_profiles)
all_profiles.pop(profile_id, None)

View file

@ -539,7 +539,7 @@ class UnifiedHealthService:
model_meta = ModelDiscoveryService.get().get_models_with_metadata(prov, pid)
if prov == "ollama":
model_meta["cloud"] = ModelDiscoveryService.get().get_models_with_metadata("ollama-cloud-catalog")
if is_authenticated and pcfg.enabled:
if is_authenticated and pcfg.enabled and not check.get("models_only"):
if check.get("state") == "checking":
health_state, health_lbl = "checking", "Проверяется…"
elif check.get("state") == "failed":

View file

@ -101,7 +101,10 @@ def get_auth_token(x_hub_token: str = Header(None)) -> bool:
@app.get("/api/health")
def health_check():
from ..account_probe_service import AccountProbeService
return {
"pid": os.getpid(),
"account_probe": AccountProbeService.get().status(),
"ok": True,
"version": __version__,
"commit": get_installed_commit(),
@ -194,6 +197,28 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)):
raise HTTPException(status_code=503, detail="Snapshot not ready")
snap_dict = dataclasses.asdict(snapshot)
from ..account_probe_service import AccountProbeService
from ..model_discovery_service import ModelDiscoveryService
probe, discovery = AccountProbeService.get(), ModelDiscoveryService.get()
snap_dict["account_probe"] = probe.status()
for profile in snap_dict.get("all_profiles", {}).values():
pid, provider = profile["profile_id"], profile["provider"]
check = probe.state(pid)
profile["connection_check"] = check
if profile.get("auth_state") == "AUTHENTICATED" and profile.get("enabled", True) and not check.get("models_only"):
if check.get("state") == "working":
profile["health_state"], profile["health_label_ru"] = "healthy", "Проверен: работает"
elif check.get("state") == "failed":
profile["health_state"], profile["health_label_ru"] = "unhealthy", "Проверен: не работает — " + check["message"]
elif check.get("state") == "checking":
profile["health_state"], profile["health_label_ru"] = "checking", "Проверяется…"
profile["model_discovery"] = discovery.get_models_with_metadata(provider, pid)
if provider == "ollama":
profile["model_discovery"]["cloud"] = discovery.get_models_with_metadata("ollama-cloud-catalog")
snap_dict["profiles_by_provider"] = {
provider: [snap_dict["all_profiles"].get(p["profile_id"], p) for p in profiles]
for provider, profiles in snap_dict.get("profiles_by_provider", {}).items()
}
snap_dict = sanitize_snapshot(snap_dict)
server_host = _web_settings().get("web_api_host", "127.0.0.1")
@ -226,13 +251,17 @@ async def handle_action(request: Request, authorized: bool = Depends(get_auth_to
threading.Thread(target=func, name=name, daemon=True).start()
actor = request.headers.get("X-Hub-Actor") or (f"web:{request.client.host}" if request.client else "user:web")
result = await run_in_threadpool(ActionExecutor.execute, action, data.get("data", {}), async_runner=_async_runner, actor=actor)
try:
result = await run_in_threadpool(ActionExecutor.execute, action, data.get("data", {}), async_runner=_async_runner, actor=actor)
except Exception as exc:
result = {"ok": False, "message": f"Действие {action} завершилось ошибкой {type(exc).__name__}"}
if result.get("unknown"):
raise HTTPException(status_code=404, detail="Неизвестное действие")
return {
"ok": result.get("ok", False),
"message": result.get("message", ""),
"message": result.get("message") or ("Действие выполнено" if result.get("ok") else f"Действие {action} не выполнено: обработчик не сообщил причину"),
"data": result.get("data", {})
}
@ -488,6 +517,7 @@ if _STATIC_DIR.is_dir():
# ─────────────────────────────────────────────────────────────
_SNAPSHOT_REFRESH_SEC = 30
_background_stop = threading.Event()
def _background_refresh_loop() -> None:
@ -509,17 +539,18 @@ def _background_refresh_loop() -> None:
except Exception as exc:
logger.debug("Initial background update check skipped: %s", exc)
while True:
while not _background_stop.is_set():
try:
AccountProbeService.get().tick()
HubStateStore.get().refresh(force_scan=False)
except Exception as exc:
logger.warning("Snapshot refresh failed: %s", exc)
time.sleep(_SNAPSHOT_REFRESH_SEC)
_background_stop.wait(_SNAPSHOT_REFRESH_SEC)
@app.on_event("startup")
def _start_background_refresh() -> None:
_background_stop.clear()
# В фоне: опрос ходит по сети к нескольким провайдерам, держать на нём
# старт сервера нельзя.
threading.Thread(target=_background_refresh_loop, daemon=True, name="hub-web-refresh").start()
@ -530,3 +561,12 @@ def _start_background_refresh() -> None:
AccountQuotaService.get().start_background_scheduler()
except Exception as exc:
logger.warning("Could not start quota scheduler: %s", exc)
@app.on_event("shutdown")
def _stop_background_refresh() -> None:
from ..account_probe_service import AccountProbeService
from ..quota_collector import AccountQuotaService
_background_stop.set()
AccountProbeService.get().shutdown()
AccountQuotaService.get().stop_background_scheduler()

View file

@ -702,7 +702,7 @@ function renderAccountsView() {
const profilesByProv = currentSnapshot.profiles_by_provider || {};
let totalProfiles = 0;
let visibleProfiles = 0;
let html = '<div style="grid-column:1/-1"><button class="btn btn-secondary" onclick="executeAction(\'check_all_accounts\', {})">Проверить все аккаунты</button></div>';
let html = '<div style="grid-column:1/-1"><button class="btn btn-secondary" onclick="executeAction(\'check_all_accounts\', {})">Проверить все аккаунты</button> <button class="btn btn-secondary" onclick="handleClearAccounts()">Очистить все аккаунты</button></div>';
for (const [providerId, profiles] of Object.entries(profilesByProv)) {
if (providerFilter !== 'all' && providerFilter !== providerId) continue;
@ -846,7 +846,7 @@ function renderAccountCard(profile) {
</div>
</div>
<div class="account-models">${(profile.preferred_models || []).map(modelBrandLabel).join('')}</div>
<div class="account-models"><span>Предпочитаемые:</span>${(profile.preferred_models || []).map(modelBrandLabel).join('')}</div>
${renderAccountCheck(profile)}
${quotaGridHtml}
</div>
@ -862,8 +862,9 @@ function renderAccountCheck(profile) {
const modelStatus = meta.error ? `Сервер отказал: ${meta.error}` : timestamp ? `Получено ${models.length} моделей · ${timestamp}` : 'Список моделей ещё не получен';
return `<div class="account-check" aria-live="polite">
${checking ? `<p>${escapeHtml(profile.display_name || profile.profile_id)}: идёт опрос провайдера, это может занять до минуты на этап.</p>` : ''}
<p>${escapeHtml(check.message || "Подключение ещё не проверялось")}</p>
<p>${escapeHtml(modelStatus)}</p>
<div class="account-models">${models.slice(0, 8).map(modelBrandLabel).join('')}</div>
<div class="account-models">${models.map(modelBrandLabel).join('')}</div>
${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>
</div>`;
@ -1516,6 +1517,10 @@ function renderHealthView() {
renderHostResources(resContainer, currentSnapshot.metrics?.host || {});
}
if (banner) {
const probe = currentSnapshot.account_probe || {};
banner.insertAdjacentHTML('beforeend', `<p class="readiness-banner-desc">Автопроверка: ${probe.enabled ? 'работает' : 'остановлена'}. Последний обход: ${probe.last_tick ? escapeHtml(new Date(probe.last_tick * 1000).toLocaleString()) : 'ещё не выполнялся'}. ${escapeHtml(probe.error || '')}</p>`);
}
renderHealthPanels(currentSnapshot);
const warningsContainer = document.getElementById('health-warnings-list');
if (warningsContainer) {
@ -2452,9 +2457,10 @@ async function handleNodeModelChange(roleId, profileId, newModel) {
async function handleRefreshProviderModels(providerId, profileId = null) {
showToast(`Запрос списка моделей для ${providerId}...`, 'info');
const res = await executeAction(profileId ? 'check_account' : 'refresh_models', { provider: providerId, profile_id: profileId || '' });
const res = await executeAction('refresh_models', { provider: providerId, profile_id: profileId || '' });
if (res && res.ok) {
showToast('Запрос обновления моделей отправлен', 'success');
showToast(res.message, 'success');
await fetchSnapshot();
if (profileId) {
setTimeout(() => openAccountDetailsModal(profileId, true), 500);
} else {
@ -2680,12 +2686,14 @@ function openAddAccountWizard() {
window._wiz_redirect_slot_id = undefined;
window._wiz_base_url = undefined;
window._wiz_token = undefined;
window._wiz_models = undefined;
if (elements.modalTitle) elements.modalTitle.textContent = 'Мастер подключения учетной записи';
showWizardStep1();
showModal();
}
function showWizardStep1() {
window._wiz_models = undefined;
stopDeviceAuthPolling();
stopRedirectAuthPolling();
for (const key of ['device_profile', 'device_session', 'redirect_session', 'redirect_provider', 'redirect_slot_id', 'base_url', 'token']) window['_wiz_' + key] = undefined;
@ -2771,6 +2779,7 @@ function showWizardStep2(providerId) {
window._wiz_device_profile = undefined;
window._wiz_base_url = undefined;
window._wiz_token = undefined;
window._wiz_models = undefined;
}
window._wiz_provider = providerId;
let bodyHtml = '';
@ -2932,7 +2941,12 @@ function showWizardStep2(providerId) {
elements.modalFooter.innerHTML = footerHtml;
}
function proceedToWizardStep3(providerId) {
async function proceedToWizardStep3(providerId) {
if (window._wiz_validating) return;
window._wiz_validating = true;
const nextButton = elements.modalFooter?.querySelector('.btn-primary');
if (nextButton) nextButton.disabled = true;
try {
const baseInput = document.getElementById('wiz-base-url-input');
if (baseInput) {
window._wiz_base_url = baseInput.value.trim();
@ -2954,7 +2968,21 @@ function proceedToWizardStep3(providerId) {
window._wiz_device_profile = redirectSlot?.value || '';
}
// For local providers (local, local-llm, llama.cpp, ollama, vllm), do not read any slot elements
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 || ''});
if (!result?.ok) {
if (feedback) feedback.textContent = result?.message || 'Нет ответа от сервера';
return;
}
window._wiz_models = result.data.models;
}
showWizardStep3(providerId);
} finally {
window._wiz_validating = false;
if (nextButton) nextButton.disabled = false;
}
}
function showWizardStep3(providerId) {
@ -2988,6 +3016,7 @@ function showWizardStep3(providerId) {
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
Шаг 3 из 3: Назначение роли для нового аккаунта
</div>
${window._wiz_models?.length ? `<label for="wiz-preferred-model">Подключение проверено. Моделей: ${window._wiz_models.length}</label><select class="select-filter" id="wiz-preferred-model">${window._wiz_models.map(model => `<option value="${escapeHtml(model)}">${escapeHtml(model)}</option>`).join('')}</select>` : ''}
<div style="margin-bottom:14px;">
<label style="display:block; font-weight:600; margin-bottom:4px;">Целевая роль в роутере:</label>
<select class="select-filter" style="width:100%;" id="wiz-target-role">
@ -3033,6 +3062,7 @@ async function finishAddAccount(providerId) {
const payload = {
provider: providerId,
target_role: targetRole,
preferred_model: document.getElementById('wiz-preferred-model')?.value || '',
// GAP-2: передаём выбранный слот, чтобы бэкенд НЕ делал find_free_slot для owner
profile_id: selectedProfileId,
};
@ -3048,7 +3078,7 @@ async function finishAddAccount(providerId) {
finally { window._wiz_saving = false; if (finishButton) finishButton.disabled = false; }
if (res && res.ok) {
showToast('Аккаунт сохранён. Идёт проверка подключения…', 'success');
showToast(res.message, 'success');
closeModal();
fetchSnapshot();
} else {
@ -3877,3 +3907,15 @@ async function setupMemoryStructure(path) {
}
}
async function handleClearAccounts() {
const preview = await executeAction('clear_accounts', {});
if (!preview?.ok) return;
const targets = preview.data.targets;
const names = targets.map(item => item.profile_id).join('\n');
if (!targets.length) { showToast('Нет аккаунтов для очистки. Antigravity защищён.', 'info'); return; }
if (!confirm(`Удалить ключи этих аккаунтов?\n${names}\n\nAntigravity не будет затронут. Повторное подключение остальных аккаунтов потребует ключей.`)) return;
const result = await executeAction('clear_accounts', {confirmed: true, targets});
showToast(result?.message || 'Нет ответа от сервера', result?.ok ? 'success' : 'error');
await fetchSnapshot();
}

View file

@ -651,7 +651,7 @@ class UpdateManager:
elif chosen_asset_name == "HermesHubSetup.exe":
try:
cmd = [str(dest_file), "/silent", "/reinstall"]
cmd = [str(dest_file), "/silent", "/reinstall", "/restart"]
creation_flags = 0
if hasattr(subprocess, "DETACHED_PROCESS") and hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
creation_flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP

View file

@ -0,0 +1,54 @@
"""A54 isolated UI execution: real local HTTP; synthetic remote credentials only."""
import json
import os
import tempfile
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from types import SimpleNamespace
os.environ['HERMES_HOME'] = tempfile.mkdtemp(prefix='a54-ui-')
os.environ['HERMES_ROUTER_CONFIG'] = os.path.join(os.environ['HERMES_HOME'], 'router_profiles.yaml')
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.profile_manager import ProfileAuthManager
from antigravity_provider.router.router_config import RouterConfig, save_router_config
from antigravity_provider.router.quota_collector import AccountQuotaService
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
from antigravity_provider.router.web import server
class Fixture(BaseHTTPRequestHandler):
def do_GET(self):
invalid = self.path.endswith('/key') and self.headers.get('Authorization') != 'Bearer fixture-valid'
self.send_response(401 if invalid else 200)
self.end_headers()
body = {'error': {'message': 'A54 fixture: invalid key'}} if invalid else ({'models': []} if self.path.endswith('/api/tags') else {'data': [{'id': 'fixture-chat'}]})
self.wfile.write(json.dumps(body).encode())
def do_POST(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b'{"choices":[{"message":{"content":"fixture OK"}}]}')
def log_message(self, *a):
pass
http = ThreadingHTTPServer(('127.0.0.1', 0), Fixture)
threading.Thread(target=http.serve_forever, daemon=True).start()
print('FIXTURE_BASE_URL=http://127.0.0.1:%s/v1' % http.server_port, flush=True)
save_router_config(RouterConfig())
for provider, pid, url in [('local', 'local-1', 'http://127.0.0.1:8081/v1'), ('ollama', 'ollama-1', f'http://127.0.0.1:{http.server_port}'), ('ollama', 'ollama-2', 'http://127.0.0.1:1')]:
AutoAssigner.ensure_profile_definition(provider, pid)
ProfileAuthManager.save_profile_auth(provider, pid, {'base_url': url, 'email': 'A54-STAND-' + pid})
# No AG account is used: rendering its 14-model fixture does not verify real OAuth.
AutoAssigner.ensure_profile_definition('antigravity', 'ag-w1')
ProfileAuthManager.save_profile_auth('antigravity', 'ag-w1', {'auth_method': 'oauth', 'email': 'A54-SYNTHETIC-AG'})
original_probe = ModelDiscoveryService.get()._probe_provider
ModelDiscoveryService.get()._probe_provider = lambda provider: ([f'gemini-fixture-{i}' for i in range(14)], None) if provider == 'antigravity' else original_probe(provider)
from antigravity_provider.router import action_handler
original_adapter = action_handler.get_adapter
action_handler.get_adapter = lambda provider: SimpleNamespace(invoke=lambda *a: {'choices': [{'message': {'content': 'AG fixture'}}]}) if provider == 'antigravity' else original_adapter(provider)
AccountQuotaService.get().fetch_all_configured = lambda **kw: None
AccountQuotaService.get().start_background_scheduler = lambda: None
server.UpdateManager.check_for_updates = lambda self: SimpleNamespace(error=None, message='A54 стенд', update_available=False, to_dict=lambda: {})
server.run_web_server(host='127.0.0.1', port=5804)

View file

@ -60,6 +60,13 @@ def setup_test_environment(tmp_path, monkeypatch):
cfg = RouterConfig()
save_router_config(cfg)
# These tests cover persistence/routing; HTTP validation has its own A54 tests.
from antigravity_provider.router.connection_preflight import DEFAULT_URLS
monkeypatch.setattr("antigravity_provider.router.connection_preflight.validate_connection", lambda provider, token='', base_url='', preferred_model='': {
"ok": True, "message": "Подключено и проверено", "data": {"models": ["fixture-model"], "base_url": base_url or DEFAULT_URLS[provider]}})
monkeypatch.setattr("antigravity_provider.router.action_handler._rescan_after_auth", lambda *args: None)
monkeypatch.setattr("antigravity_provider.router.account_probe_service.AccountProbeService.check_now", lambda *args, **kwargs: {"ok": True, "message": "Проверено", "data": {}})
yield hermes_home
@ -111,7 +118,7 @@ def test_p0_1_add_account_openrouter_default_base_url():
},
)
assert res["ok"] is True
assert "сохранён" in res["message"]
assert "проверено" in res["message"]
# Verify profile created and auth saved
auth = ProfileAuthManager.load_profile_auth("openrouter", "openrouter-1")
@ -137,7 +144,7 @@ def test_p0_1_add_account_nvidia_default_base_url():
},
)
assert res["ok"] is True
assert "сохранён" in res["message"]
assert "проверено" in res["message"]
auth = ProfileAuthManager.load_profile_auth("nvidia", "nvidia-1")
assert auth is not None

View file

@ -66,6 +66,13 @@ def setup_isolated_env(tmp_path, monkeypatch):
UnifiedHealthService._instance = None
HubStateStore._instance = None
# These tests cover persistence/routing; HTTP validation has its own A54 tests.
from antigravity_provider.router.connection_preflight import DEFAULT_URLS
monkeypatch.setattr("antigravity_provider.router.connection_preflight.validate_connection", lambda provider, token='', base_url='', preferred_model='': {
"ok": True, "message": "Подключено и проверено", "data": {"models": ["fixture-model"], "base_url": base_url or DEFAULT_URLS[provider]}})
monkeypatch.setattr("antigravity_provider.router.action_handler._rescan_after_auth", lambda *args: None)
monkeypatch.setattr("antigravity_provider.router.account_probe_service.AccountProbeService.check_now", lambda *args, **kwargs: {"ok": True, "message": "Проверено", "data": {}})
yield {
"hermes_home": hermes_home,
"profiles_yaml": profiles_yaml,

View file

@ -22,7 +22,7 @@ def isolated(tmp_path, monkeypatch):
monkeypatch.setattr(AccountProbeService, "_instance", service)
discovery = ModelDiscoveryService(tmp_path / "test-model-cache.json")
monkeypatch.setattr(ModelDiscoveryService, "_instance", discovery)
monkeypatch.setattr("antigravity_provider.router.action_handler._rescan_after_auth", lambda: None)
monkeypatch.setattr("antigravity_provider.router.action_handler._rescan_after_auth", lambda *args: None)
monkeypatch.setattr("antigravity_provider.router.state_store.HubStateStore.refresh", lambda *a, **kw: None)
ActionExecutor._pending_connections.clear()
yield service, discovery
@ -65,16 +65,10 @@ def test_invalid_key_real_http_correct_slot(isolated, provider):
"provider": provider, "token": "intentionally-invalid",
"base_url": f"http://127.0.0.1:{server.server_port}/v1",
})
assert result["ok"]
pid = result["data"]["profile_id"]
assert pid.startswith(provider + "-")
assert load_router_config().get_profile(pid).provider == provider
probe = do_test_profile(provider, pid)
assert not probe["success"]
assert "401" in probe["error"] and "Invalid API key" in probe["error"]
discovery = isolated[1]
assert discovery.discover_models_sync(provider, profile_id=pid) is None
assert "Invalid API key" in discovery.get_models_with_metadata(provider, pid)["error"]
assert not result["ok"]
assert "401" in result["message"]
assert not load_router_config().profiles
finally:
server.shutdown()
server.server_close()
@ -187,13 +181,9 @@ def test_slow_action_does_not_block_web_health(isolated, monkeypatch):
asyncio.run(exercise())
def test_repeated_connection_rejected_while_checking(isolated, monkeypatch):
service, _ = isolated
service.enabled = True
monkeypatch.setattr(service._pool, "submit", lambda *args: None)
payload = {"provider": "nvidia", "token": "intentionally-invalid-repeated"}
first = ActionExecutor.execute("add_account", payload)
second = ActionExecutor.execute("add_account", payload)
assert first["ok"] and first["data"]["profile_id"] == "nvidia-1"
assert not second["ok"] and "проверяется" in second["message"]
assert list(load_router_config().profiles) == ["nvidia-1"]
def test_repeated_invalid_connection_does_not_create_profiles(isolated):
# Repeated invalid input cannot consume slots or create placeholder credentials.
payload = {"provider": "nvidia", "token": "invalid", "base_url": "invalid-url"}
assert not ActionExecutor.execute("add_account", payload)["ok"]
assert not ActionExecutor.execute("add_account", payload)["ok"]
assert not load_router_config().profiles

242
tests/test_accounts_a54.py Normal file
View file

@ -0,0 +1,242 @@
"""A54: real loopback HTTP, no owner credentials, no inference-service load."""
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from types import SimpleNamespace
import pytest
from antigravity_provider.router.account_probe_service import AccountProbeService
from antigravity_provider.router.action_handler import ActionExecutor, do_delete_credentials
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.connection_preflight import validate_connection
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
from antigravity_provider.router.profile_manager import ProfileAuthManager, get_profile_auth_path
from antigravity_provider.router.router_config import RouterConfig, load_router_config, save_router_config
@pytest.fixture
def services(tmp_path, monkeypatch):
save_router_config(RouterConfig())
probe = AccountProbeService()
discovery = ModelDiscoveryService(tmp_path / 'models.json')
monkeypatch.setattr(AccountProbeService, '_instance', probe)
monkeypatch.setattr(ModelDiscoveryService, '_instance', discovery)
monkeypatch.setattr('antigravity_provider.router.action_handler._rescan_after_auth', lambda *a: None)
yield probe, discovery
probe.shutdown()
@pytest.fixture
def http_provider():
calls = []
class Provider(BaseHTTPRequestHandler):
def do_GET(self):
calls.append(self.path)
invalid = self.headers.get('Authorization') == 'Bearer bad' and self.path.endswith('/key')
body = {'error': {'message': 'Invalid key'}} if invalid else ({'models': []} if self.path.endswith('/api/tags') else {'data': [{'id': 'fixture-chat'}]})
self.send_response(401 if invalid else 200)
self.end_headers()
self.wfile.write(json.dumps(body).encode())
def do_POST(self):
calls.append(self.path)
body = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
assert body['max_tokens'] == 1
valid = self.headers.get('Authorization') == 'Bearer fixture-valid'
self.send_response(200 if valid else 401)
self.end_headers()
self.wfile.write(json.dumps({'choices': [{'message': {'content': 'ok'}}]} if valid else {'error': {'message': 'Invalid key'}}).encode())
def log_message(self, *args):
pass
server = ThreadingHTTPServer(('127.0.0.1', 0), Provider)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield f'http://127.0.0.1:{server.server_port}/v1', calls
server.shutdown()
server.server_close()
thread.join()
def test_manual_check_works_when_periodic_disabled(services, monkeypatch):
probe, discovery = services
calls = []
monkeypatch.setattr(discovery, 'discover_models_sync', lambda *a, **kw: ['fixture'])
def invoke(*a, **kw):
calls.append(kw)
return {'success': True, 'response': 'Provider answered'}
monkeypatch.setattr('antigravity_provider.router.action_handler.do_test_profile', invoke)
assert probe.enabled is False
result = probe.check_now('local', 'local-1')
assert result['ok'] and result['message'] == 'Provider answered'
assert len(calls) == 1 and calls[0]['discovered_models'] == ['fixture']
def test_models_only_empty_is_success_without_inference(services, http_provider, monkeypatch):
probe, _ = services
url, calls = http_provider
AutoAssigner.ensure_profile_definition('ollama', 'ollama-1')
ProfileAuthManager.save_profile_auth('ollama', 'ollama-1', {'base_url': url})
monkeypatch.setattr('antigravity_provider.router.action_handler.do_test_profile', lambda *a, **kw: pytest.fail('catalog called inference'))
result = probe.check_now('ollama', 'ollama-1', models_only=True)
assert result['ok'] and result['data']['models'] == []
assert calls == ['/api/tags']
@pytest.mark.parametrize('provider', ['openrouter', 'nvidia'])
def test_invalid_key_never_creates_profile(services, http_provider, provider):
url, _ = http_provider
result = ActionExecutor.execute('add_account', {'provider': provider, 'token': 'bad', 'base_url': url})
assert not result['ok'] and '401' in result['message']
assert not load_router_config().profiles
assert not get_profile_auth_path(provider, provider + '-1').exists()
@pytest.mark.parametrize('provider', ['openrouter', 'nvidia', 'local', 'ollama'])
def test_valid_preflight_returns_catalog(services, http_provider, provider):
url, calls = http_provider
result = validate_connection(provider, 'fixture-valid', url)
assert result['ok'] and result['message']
assert result['data']['models'] == ([] if provider == 'ollama' else ['fixture-chat'])
assert ('/v1/chat/completions' in calls) == (provider == 'nvidia')
@pytest.mark.parametrize('url', ['bad-url', 'http://user:pass@localhost/v1', 'http://localhost/v1?key=abc', 'file:///tmp/models'])
def test_preflight_rejects_invalid_urls(url):
result = validate_connection('local', '', url)
assert not result['ok'] and result['message']
def test_empty_exception_is_visible(services, monkeypatch):
probe, discovery = services
def fail(*a, **kw):
raise RuntimeError()
monkeypatch.setattr(discovery, 'discover_models_sync', fail)
result = probe.check_now('local', 'local-1')
assert not result['ok'] and result['message'] == 'RuntimeError'
def test_periodic_failure_visible_and_retry(services, monkeypatch):
probe, _ = services
probe.enabled = True
monkeypatch.setattr(probe, 'schedule_all', lambda **kw: (_ for _ in ()).throw(ValueError('sweep failed')))
assert probe.tick(1) == 0 and probe.status()['error'] == 'sweep failed'
monkeypatch.setattr(probe, 'schedule_all', lambda **kw: 3)
assert probe.tick(2) == 3
assert probe.status()['last_tick'] and probe.status()['error'] is None
def test_manual_background_do_not_overlap(services, monkeypatch):
probe, discovery = services
probe.enabled = True
entered, release = threading.Event(), threading.Event()
concurrent, maximum = 0, 0
def discover(*a, **kw):
nonlocal concurrent, maximum
concurrent += 1
maximum = max(maximum, concurrent)
entered.set()
assert release.wait(3)
concurrent -= 1
return ['fixture']
monkeypatch.setattr(discovery, 'discover_models_sync', discover)
monkeypatch.setattr('antigravity_provider.router.action_handler.do_test_profile', lambda *a, **kw: {'success': True, 'response': 'ok'})
assert probe.schedule('local', 'local-1')
assert entered.wait(2)
result = []
manual = threading.Thread(target=lambda: result.append(probe.check_now('local', 'local-1')))
manual.start()
assert not probe.schedule('local', 'local-1', force=True)
release.set()
manual.join(3)
assert result[0]['ok'] and maximum == 1
def test_delete_fast_and_bulk_protects_antigravity(services, monkeypatch):
from antigravity_provider.router.state_store import HubStateStore
removed = []
monkeypatch.setattr(HubStateStore, 'get', lambda: SimpleNamespace(apply_delta_account_removed=lambda *a: removed.append(a)))
for provider, pid in [('antigravity', 'ag-w1'), ('nvidia', 'nvidia-1'), ('local', 'local-1')]:
AutoAssigner.ensure_profile_definition(provider, pid)
path = get_profile_auth_path(provider, pid)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('{"fixture":true}')
protected = get_profile_auth_path('antigravity', 'ag-w1')
before = protected.read_bytes()
started = time.monotonic()
assert do_delete_credentials('local', 'local-1')[0]
assert time.monotonic() - started < 0.5
preview = ActionExecutor.execute('clear_accounts', {})
assert 'ag-w1' in preview['data']['protected']
assert protected.read_bytes() == before
result = ActionExecutor.execute('clear_accounts', {'confirmed': True, 'targets': preview['data']['targets']})
assert result['ok'] and protected.read_bytes() == before
assert not get_profile_auth_path('nvidia', 'nvidia-1').exists()
assert len(removed) == 2
def test_bulk_stale_preview_rejected(services):
result = ActionExecutor.execute('clear_accounts', {'confirmed': True, 'targets': [{'profile_id': 'forged'}]})
assert not result['ok'] and 'изменился' in result['message']
def test_antigravity_explicit_catalog_ignores_global_cache(monkeypatch):
from antigravity_provider import agy_subprocess as agy
monkeypatch.setattr(agy, '_AGY_MODEL_CACHE', {'old': 'old'})
monkeypatch.setattr(agy, 'get_agy_exe', lambda: 'fixture-agy')
monkeypatch.setattr(agy.subprocess, 'run', lambda *a, **kw: SimpleNamespace(returncode=0, stdout='\n'.join(f'gemini-{i}\tfixture' for i in range(14))))
assert len(agy.discover_models('ag-w1')) == 14
def test_valid_add_remembers_check_and_selected_model(services, http_provider):
probe, discovery = services
url, _ = http_provider
result = ActionExecutor.execute('add_account', {'provider': 'openrouter', 'token': 'fixture-valid', 'base_url': url, 'preferred_model': 'fixture-chat'})
assert result['ok']
pid = result['data']['profile_id']
assert probe.state(pid)['state'] == 'working'
assert discovery.get_models_with_metadata('openrouter', pid)['models'] == ['fixture-chat']
assert load_router_config().get_profile(pid).preferred_models[0] == 'fixture-chat'
def test_timeout_keeps_inference_reserved(services, monkeypatch):
from antigravity_provider.router.action_handler import do_test_profile
AutoAssigner.ensure_profile_definition('local', 'local-1')
ProfileAuthManager.save_profile_auth('local', 'local-1', {'base_url': 'http://127.0.0.1:1/v1'})
entered, release, finished = threading.Event(), threading.Event(), threading.Event()
calls = []
def invoke(*a, **kw):
calls.append(1)
entered.set()
try:
assert release.wait(2)
return {}
finally:
finished.set()
monkeypatch.setattr('antigravity_provider.router.action_handler.get_adapter', lambda p: SimpleNamespace(invoke=invoke))
try:
first = do_test_profile('local', 'local-1', timeout=0.02, discovered_models=['fixture'])
assert entered.is_set() and not first['success']
second = do_test_profile('local', 'local-1', timeout=0.02, discovered_models=['fixture'])
assert not second['success'] and 'ещё не завершился' in second['error']
assert calls == [1]
finally:
release.set()
assert finished.wait(2)
def test_bulk_protects_symlink_into_ag(services, tmp_path):
AutoAssigner.ensure_profile_definition('local', 'local-1')
target = get_profile_auth_path('antigravity', 'ag-w1')
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text('protected fixture')
link = get_profile_auth_path('local', 'local-1')
link.parent.mkdir(parents=True, exist_ok=True)
link.symlink_to(target)
preview = ActionExecutor.execute('clear_accounts', {})
assert 'local-1' in preview['data']['protected']
assert preview['data']['targets'] == []
assert ActionExecutor.execute('clear_accounts', {'confirmed': True, 'targets': []})['ok']
assert target.read_text() == 'protected fixture'

View file

@ -10,17 +10,13 @@ Acceptance criteria verification:
from __future__ import annotations
import json
import os
import subprocess
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from antigravity_provider.agy_subprocess import (
check_profile_native_auth_status,
launch_native_agy_login,
)
from antigravity_provider.paths import get_profile_dir
from antigravity_provider.router.action_handler import do_set_model
@ -106,35 +102,9 @@ def test_profile_operations_never_touch_user_home_gemini(tmp_path, monkeypatch):
@pytest.mark.unit
def test_launch_native_agy_login_env_isolation(tmp_path, monkeypatch):
"""P0-2: launch_native_agy_login launches agy in profile's isolated directory."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
slot = "ag-w2"
expected_pdir = get_profile_dir(slot, "antigravity")
with patch("subprocess.Popen") as mock_popen, \
patch("antigravity_provider.agy_subprocess.get_agy_exe", return_value="C:\\fake\\agy.exe"):
mock_popen.return_value = MagicMock()
proc = launch_native_agy_login(slot)
assert proc is not None
mock_popen.assert_called_once()
args, kwargs = mock_popen.call_args
# Command includes the mocked agy executable (Linux wraps it in a terminal).
launched = args[0]
assert "C:\\fake\\agy.exe" in launched
# Environment points to profile dir
env = kwargs.get("env", {})
assert env.get("USERPROFILE") == str(expected_pdir)
assert env.get("HOME") == str(expected_pdir)
assert env.get("HOMEPATH") == str(expected_pdir)
# Profile .gemini directory was created
assert (expected_pdir / ".gemini").is_dir()
def test_unused_console_login_removed():
from antigravity_provider import agy_subprocess
assert not hasattr(agy_subprocess, "launch_native_agy_login")
# ── TEST 3: Detection of native agy authentication ──

View file

@ -247,7 +247,7 @@ async function runTests() {
const slotForStep3 = getOrCreateElement('wiz-device-slot');
slotForStep3.value = 'owner-slot-42';
// proceedToWizardStep3 replaces modalBody (destroying wiz-device-slot)
sandbox.proceedToWizardStep3('grok');
await sandbox.proceedToWizardStep3('grok');
// finishAddAccount reads from destroyed selects — P0-1 BUG-1: profile_id must survive
const targetRoleForStep3 = getOrCreateElement('wiz-target-role');
targetRoleForStep3.value = 'coder-primary';
@ -274,7 +274,7 @@ async function runTests() {
sandbox.showWizardStep2('local');
const baseInput = getOrCreateElement('wiz-base-url-input');
baseInput.value = 'http://127.0.0.1:8081/v1';
sandbox.proceedToWizardStep3('local');
await sandbox.proceedToWizardStep3('local');
const targetRole = getOrCreateElement('wiz-target-role');
targetRole.value = 'coder-primary';
await sandbox.finishAddAccount('local');
@ -310,13 +310,13 @@ async function runTests() {
sandbox.openAddAccountWizard();
sandbox.showWizardStep2('antigravity');
getOrCreateElement('wiz-redirect-slot').value = 'ag-w1';
sandbox.proceedToWizardStep3('antigravity');
await sandbox.proceedToWizardStep3('antigravity');
assert.strictEqual(sandbox.window._wiz_device_profile, 'ag-w1');
sandbox.showWizardStep1();
sandbox.showWizardStep2(provider);
getOrCreateElement('wiz-redirect-slot').value = '';
getOrCreateElement('wiz-token-input').value = 'intentionally-invalid';
sandbox.proceedToWizardStep3(provider);
await sandbox.proceedToWizardStep3(provider);
await sandbox.finishAddAccount(provider);
const action = [...executedActions].reverse().find(a => a.action === 'add_account');
assert.strictEqual(action.data.provider, provider);