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; 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) public static int PerformInstall(string sourceRoot, Action<string, int> progressCallback = null)
{ {
if (!IsHermesFound) return 10; if (!IsHermesFound) return 10;
try try
{ {
StopOwnedRuntime(HermesHome, true);
if (progressCallback != null) progressCallback("Preparing installation directory...", 10); if (progressCallback != null) progressCallback("Preparing installation directory...", 10);
if (!Directory.Exists(TargetInstallDir)) if (!Directory.Exists(TargetInstallDir))
{ {
@ -1074,12 +1102,14 @@ namespace HermesHubSetup
Application.SetCompatibleTextRenderingDefault(false); Application.SetCompatibleTextRenderingDefault(false);
bool isSilent = false; bool isSilent = false;
bool restartAfterInstall = false;
bool isUninstall = false; bool isUninstall = false;
bool isRepair = false; bool isRepair = false;
bool purgeUserData = false; bool purgeUserData = false;
foreach (string a in args) 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("/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("/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; 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); 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); Console.WriteLine("Silent install result: " + code);
return code; return code;
} }

View file

@ -11,6 +11,8 @@ namespace HermesHub
{ {
public static class WebLauncher public static class WebLauncher
{ {
private static Mutex instanceMutex;
[STAThread] [STAThread]
public static void Main(string[] args) public static void Main(string[] args)
{ {
@ -69,9 +71,17 @@ namespace HermesHub
string targetUrl = string.Format("http://{0}:{1}/", host, port); string targetUrl = string.Format("http://{0}:{1}/", host, port);
string healthUrl = string.Format("http://{0}:{1}/api/health", 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 // 2. Check if server is already running and healthy
bool serverWasAlreadyRunning = IsServerHealthy(healthUrl); bool serverWasAlreadyRunning = IsServerHealthy(healthUrl);
Process serverProcess = null; Process serverProcess = null;
StringBuilder serverLog = new StringBuilder();
if (!serverWasAlreadyRunning) if (!serverWasAlreadyRunning)
{ {
@ -131,6 +141,14 @@ namespace HermesHub
try try
{ {
serverProcess = Process.Start(serverPsi); 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) catch (Exception ex)
{ {
@ -149,12 +167,8 @@ namespace HermesHub
} }
if (serverProcess.HasExited) if (serverProcess.HasExited)
{ {
string why = ""; string why;
try { why = serverProcess.StandardError.ReadToEnd(); } catch { } lock (serverLog) { why = serverLog.ToString(); }
if (string.IsNullOrEmpty(why))
{
try { why = serverProcess.StandardOutput.ReadToEnd(); } catch { }
}
if (why.Length > 1500) why = why.Substring(why.Length - 1500); if (why.Length > 1500) why = why.Substring(why.Length - 1500);
string msg = "Веб-сервер Hermes Hub завершился с ошибкой."; string msg = "Веб-сервер Hermes Hub завершился с ошибкой.";
if (!string.IsNullOrEmpty(why)) msg += Environment.NewLine + Environment.NewLine + why.Trim(); 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 // 4. Locate browser in strict priority: Edge -> Chrome -> Chromium registry -> Fallback
string browserPath = FindChromiumBrowser(); string browserPath = FindChromiumBrowser();
Process browserProc = null; Process browserProc = null;
DateTime browserStartedAt = DateTime.UtcNow;
if (!string.IsNullOrEmpty(browserPath)) if (!string.IsNullOrEmpty(browserPath))
{ {
@ -199,7 +213,7 @@ namespace HermesHub
try try
{ {
browserProc = Process.Start(browserPsi); browserProc = Process.Start(browserPsi);
browserStartedAt = DateTime.UtcNow;
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -226,36 +240,113 @@ namespace HermesHub
} }
} }
// 5. Server Lifecycle: Application.Run(new HubContext(hermesHome, targetUrl, browserPath, browserProc));
// If the server was started by this launcher session and browser is tracked, instanceMutex.ReleaseMutex();
// wait for browser window to close, then gracefully terminate server process. }
if (!serverWasAlreadyRunning && serverProcess != null && !serverProcess.HasExited && browserProc != null)
// 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 if (!process.WaitForExit(20000)) { process.Kill(); throw new IOException("Остановка прежнего сервера превысила 20 секунд"); }
{ if (process.ExitCode != 0) throw new IOException("Не удалось остановить прежний сервер. Обновление отменено.");
browserProc.WaitForExit();
}
catch { }
// Подстраховка: если процесс браузера завершился почти сразу,
// это почти наверняка передача окна другому экземпляру, а не
// закрытие пользователем. Убивать сервер в этом случае нельзя.
if (DateTime.UtcNow - browserStartedAt < TimeSpan.FromSeconds(5))
{
return;
}
try
{
if (!serverProcess.HasExited)
{
serverProcess.Kill();
}
}
catch { }
} }
} }
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) public static bool IsServerHealthy(string url)
{ {
try try

View file

@ -50,6 +50,38 @@ if ([string]::IsNullOrWhiteSpace($TargetDir)) {
Write-Host "[2/6] Preparing installation target: $TargetDir" -ForegroundColor Yellow Write-Host "[2/6] Preparing installation target: $TargetDir" -ForegroundColor Yellow
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null 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 # 4. Copy Application Files to TargetDir
$RepoRoot = Split-Path -Parent $PSScriptRoot $RepoRoot = Split-Path -Parent $PSScriptRoot
Write-Host "[3/6] Deploying application binaries..." -ForegroundColor Yellow 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. Also populates :data:`_AGY_EFFORT_MAP` with supported efforts per model.
""" """
global _AGY_MODEL_CACHE, _AGY_EFFORT_MAP 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) return dict(_AGY_MODEL_CACHE)
exe = get_agy_exe() exe = get_agy_exe()
@ -166,23 +166,13 @@ def discover_models(profile_id: str | None = None) -> dict[str, str]:
) )
raw = result.stdout.strip() raw = result.stdout.strip()
if not raw or result.returncode != 0: if not raw or result.returncode != 0:
logger.warning("discover_models: agy models failed or returned empty output (rc=%s)", result.returncode) if profile_id:
if _AGY_MODEL_CACHE is None: raise RuntimeError(f"agy models: код {result.returncode}; каталог не получен")
_AGY_MODEL_CACHE = {} return dict(_AGY_MODEL_CACHE or {})
_AGY_EFFORT_MAP = {} except Exception:
return dict(_AGY_MODEL_CACHE) if profile_id:
except subprocess.TimeoutExpired: raise
logger.warning("discover_models: agy models timed out") return dict(_AGY_MODEL_CACHE or {})
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)
models: dict[str, str] = {} models: dict[str, str] = {}
effort_map: dict[str, set[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) 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]: 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. """Check if agy native authentication has completed in profile's directory.

View file

@ -14,6 +14,11 @@ class AccountProbeService:
def __init__(self) -> None: def __init__(self) -> None:
self.enabled = False self.enabled = False
self._next_check = 0.0 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._states: dict[str, dict[str, Any]] = {}
self._lock = threading.Lock() self._lock = threading.Lock()
self._pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="account-probe") self._pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="account-probe")
@ -29,66 +34,134 @@ class AccountProbeService:
with self._lock: with self._lock:
return dict(self._states.get(profile_id, {"state": "never_checked"})) return dict(self._states.get(profile_id, {"state": "never_checked"}))
def schedule(self, provider: str, profile_id: str, *, force: bool = False) -> bool: def _profile_lock(self, profile_id: str):
if not self.enabled: with self._lock:
return False return self._profile_locks.setdefault(profile_id, threading.Lock())
def _mark_checking(self, provider, profile_id):
with self._lock: 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] = { self._states[profile_id] = {
**current, "state": "checking", "provider": provider, **self._states.get(profile_id, {}), "state": "checking",
"started_at": time.time(), "message": "Идёт опрос провайдера — это может занять до минуты", "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 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: def tick(self, now: Optional[float] = None) -> int:
from .settings_service import get_hub_settings from .settings_service import get_hub_settings
now = time.monotonic() if now is None else now now = time.monotonic() if now is None else now
if not self.enabled or now < self._next_check: if not self.enabled or now < self._next_check:
return 0 return 0
self._next_check = now + get_hub_settings()["account_check_interval_seconds"] try:
return self.schedule_all(force=True) 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: def schedule_all(self, *, force: bool = False) -> int:
from .profile_manager import ProfileAuthManager from .profile_manager import ProfileAuthManager
from .router_config import load_router_config from .router_config import load_router_config
count = 0 count = 0
has_ollama = False
for pid, pcfg in load_router_config().profiles.items(): for pid, pcfg in load_router_config().profiles.items():
if pcfg.enabled and ProfileAuthManager.get_profile_status(pcfg.provider, pid).get("authenticated"): if pcfg.enabled and ProfileAuthManager.get_profile_status(pcfg.provider, pid).get("authenticated"):
count += int(self.schedule(pcfg.provider, pid, force=force)) 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 return count
def _run(self, provider: str, profile_id: str) -> None: 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 .action_handler import do_test_profile
from .model_discovery_service import ModelDiscoveryService from .model_discovery_service import ModelDiscoveryService
models, meta = None, {}
try: try:
from .state_store import HubStateStore discovery = ModelDiscoveryService.get()
HubStateStore.get().refresh(force_scan=True) models = discovery.discover_models_sync(provider, timeout=65 if provider == "antigravity" else 20, profile_id=profile_id)
models = ModelDiscoveryService.get().discover_models_sync(provider, timeout=20, profile_id=profile_id) meta = discovery.get_models_with_metadata(provider, profile_id)
HubStateStore.get().refresh(force_scan=True) if models is None and meta.get("error"):
if provider == "ollama": success, message = False, meta["error"]
cloud = ModelDiscoveryService.get().get_models_with_metadata("ollama-cloud-catalog") elif models_only:
if cloud.get("is_stale"): success = models is not None and not meta.get("error")
ModelDiscoveryService.get().discover_ollama_cloud() message = meta.get("error") or (f"Получено моделей: {len(models)}" if success else "Провайдер не вернул каталог моделей")
result = do_test_profile(provider, profile_id, timeout=60, discovered_models=models) else:
meta = ModelDiscoveryService.get().get_models_with_metadata(provider, profile_id) result = do_test_profile(provider, profile_id, timeout=60, discovered_models=models)
success = bool(result.get("success")) success = bool(result.get("success"))
message = result.get("response") or result.get("error") or "Проверка завершена без пояснения" message = result.get("response") or result.get("error") or "Провайдер не сообщил причину результата проверки"
state = "working" if success else "failed" state = "working" if success else "failed"
except Exception as exc: 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: with self._lock:
self._states[profile_id] = { self._states[profile_id] = record
"state": state, "provider": provider, "checked_at": time.time(), return {"ok": success, "message": message, "data": record}
"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

View file

@ -18,6 +18,8 @@ from antigravity_provider import paths
from antigravity_provider.router.adapters import get_adapter from antigravity_provider.router.adapters import get_adapter
logger = logging.getLogger('hermes.router.actions') 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]: def do_set_main(provider: str, profile_id: str) -> Tuple[bool, str]:
ok, msg = ProfileAuthManager.set_main_profile(provider, profile_id) 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': if status.get('is_expired') or status.get('expired') or status.get('status') == 'EXPIRED':
return {'success': False, 'error': 'Авторизация истекла, требуется повторный вход.'} 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() t0 = time.time()
try: try:
auth_data = ProfileAuthManager.load_profile_auth(pcfg.provider, profile_id) 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 = [] result_container = []
error_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(): def _call_invoke():
try: try:
result_container.append(adapter.invoke(pcfg, req)) result_container.append(adapter.invoke(pcfg, req))
except Exception as e: except Exception as e:
error_container.append(e) error_container.append(e)
finally:
invoke_lock.release()
t = threading.Thread(target=_call_invoke, daemon=True) t = threading.Thread(target=_call_invoke, daemon=True)
t.start() try:
t.start()
except Exception:
invoke_lock.release()
raise
t.join(timeout=timeout) t.join(timeout=timeout)
el = round(time.time() - t0, 2) 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: except Exception as e:
EventLogService.get().log('system', f'Сбой проверки {profile_id} ({model}): {e}', level='error') 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]: def do_delete_credentials(provider: str, profile_id: str, actor: str = "system") -> Tuple[bool, str]:
# Сигнатура get_profile_dir — (profile_id, provider), а здесь её звали # Сигнатура 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(): if auth_p.is_file():
try: try:
auth_p.unlink() auth_p.unlink()
from .state_store import HubStateStore
HubStateStore.get().apply_delta_account_removed(provider, profile_id)
EventLogService.get().log( EventLogService.get().log(
'account', 'account',
f'Учетные данные для {profile_id} удалены.', 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 updated.roles[role_id].default_model = model
if save_router_config(updated): if save_router_config(updated):
try: from .state_store import HubStateStore
from antigravity_provider.router.state_store import HubStateStore HubStateStore.get().apply_delta_profile_preferences(profile_id, target.preferred_models)
HubStateStore.get().refresh(force_scan=True)
except Exception:
pass
EventLogService.get().log( EventLogService.get().log(
"model", f"Для профиля {profile_id} ({provider}) установлена модель '{model}'.", level="info" "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 внутри # не всем точкам завершения входа, и device-flow получал NameError внутри
# обработки успеха. # обработки успеха.
def _rescan_after_auth() -> None: def _rescan_after_auth(provider=None, profile_id=None) -> None:
try: def refresh():
from antigravity_provider.router.state_store import HubStateStore try:
from .state_store import HubStateStore
HubStateStore.get().refresh(force_scan=True) from .account_probe_service import AccountProbeService
from .account_probe_service import AccountProbeService if provider and profile_id:
AccountProbeService.get().schedule_all() HubStateStore.get().apply_delta_account_added(provider, profile_id)
except Exception as exc: # пересбор не должен ронять сам вход else:
logger.warning("Не удалось пересобрать снапшот после входа: %s", exc) 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: def generate_quotas_export(format: str = "json") -> Any:
@ -686,6 +705,10 @@ class ActionExecutor:
return {'ok': False, 'message': reason, 'data': {'status': status}} return {'ok': False, 'message': reason, 'data': {'status': status}}
return {'ok': True, 'message': 'Ожидание подтверждения', 'data': {'status': status}} return {'ok': True, 'message': 'Ожидание подтверждения', 'data': {'status': status}}
if action == '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) # Подключение аккаунта: сохранение профиля и учетных данных (P0-1)
if action == 'add_account': if action == 'add_account':
prov_norm = (prov or data.get('provider') or '').strip().lower() 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() token = (data.get('token') or data.get('api_key') or '').strip()
slot = data.get('profile_id') 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' slot = slot or AutoAssigner.find_free_slot(prov_norm) or f'{prov_norm}-1'
status = ProfileAuthManager.get_profile_status(prov_norm, slot) 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) 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 from antigravity_provider.router.account_probe_service import AccountProbeService
AccountProbeService.get().schedule(prov_norm, slot, force=True) if validation:
check_note = 'проверка запускается в фоне' if AccountProbeService.get().enabled else 'проверка Н/Д: фоновая служба не запущена' AccountProbeService.get().record_validation(prov_norm, slot, validation)
return {'ok': True, 'message': f'Аккаунт {prov_norm} ({slot}) сохранён; {check_note}', 'data': {'profile_id': slot}} 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: except Exception as e:
return {'ok': False, 'message': f'Ошибка при сохранении учетных данных {slot}: {e}'} return {'ok': False, 'message': f'Ошибка при сохранении учетных данных {slot}: {e}'}
@ -942,6 +986,29 @@ class ActionExecutor:
res = do_test_profile(prov, pid) res = do_test_profile(prov, pid)
return {'ok': res.get('success', False), 'message': res.get('response') or res.get('error'), 'data': res} 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': elif action == 'delete_credentials':
dry_run = bool(data.get('dry_run', False)) dry_run = bool(data.get('dry_run', False))
confirmed = bool(data.get('confirmed', True)) confirmed = bool(data.get('confirmed', True))
@ -1042,13 +1109,10 @@ class ActionExecutor:
elif action == 'check_account': elif action == 'check_account':
from antigravity_provider.router.account_probe_service import AccountProbeService 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) valid, reason = AutoAssigner.validate_slot(prov, pid)
if not valid: if not valid:
return {'ok': False, 'message': reason} return {'ok': False, 'message': reason or 'Неверный профиль'}
started = AccountProbeService.get().schedule(prov, pid, force=True) return AccountProbeService.get().check_now(prov, pid)
return {'ok': True, 'message': 'Проверка запущена' if started else 'Проверка уже выполняется'}
elif action == 'check_all_accounts': elif action == 'check_all_accounts':
from antigravity_provider.router.account_probe_service import AccountProbeService from antigravity_provider.router.account_probe_service import AccountProbeService
@ -1074,6 +1138,12 @@ class ActionExecutor:
return {'ok': True, 'message': 'Успешно'} return {'ok': True, 'message': 'Успешно'}
elif action == 'refresh_models': 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 from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
service = ModelDiscoveryService.get() service = ModelDiscoveryService.get()
if prov: 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 # 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]]: def get_models(self, provider: str) -> Optional[List[str]]:
"""Return cached models for provider immediately, or None if undiscovered.""" """Return cached models for provider immediately, or None if undiscovered."""
meta = self.get_models_with_metadata(provider) meta = self.get_models_with_metadata(provider)
@ -203,7 +210,7 @@ class ModelDiscoveryService:
result_holder[0] = models result_holder[0] = models
error_holder[0] = err_msg error_holder[0] = err_msg
except Exception as exc: 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 = threading.Thread(target=_do_probe, daemon=True)
worker.start() worker.start()
@ -412,7 +419,7 @@ class ModelDiscoveryService:
last_err = self._extract_http_error(http_err) last_err = self._extract_http_error(http_err)
logger.debug("Codex model query HTTP error on %s: %s", pid, last_err) logger.debug("Codex model query HTTP error on %s: %s", pid, last_err)
except Exception as exc: 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) logger.debug("Codex model query failed on %s: %s", pid, exc)
return None, last_err or "Отсутствуют учетные данные для OpenAI Codex" return None, last_err or "Отсутствуют учетные данные для OpenAI Codex"
@ -448,7 +455,7 @@ class ModelDiscoveryService:
last_err = self._extract_http_error(http_err) last_err = self._extract_http_error(http_err)
logger.debug("OpenCode model query HTTP error on %s: %s", pid, last_err) logger.debug("OpenCode model query HTTP error on %s: %s", pid, last_err)
except Exception as exc: 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) logger.debug("OpenCode model query failed on %s: %s", pid, exc)
return None, last_err or "Отсутствуют учетные данные для OpenCode Go" return None, last_err or "Отсутствуют учетные данные для OpenCode Go"
@ -484,7 +491,7 @@ class ModelDiscoveryService:
last_err = self._extract_http_error(http_err) last_err = self._extract_http_error(http_err)
logger.debug("Grok model discovery HTTP error for %s: %s", pid, last_err) logger.debug("Grok model discovery HTTP error for %s: %s", pid, last_err)
except Exception as exc: 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) logger.debug("Grok model discovery failed for %s: %s", pid, exc)
return None, last_err or "Отсутствуют учетные данные для Grok" return None, last_err or "Отсутствуют учетные данные для Grok"
@ -523,7 +530,7 @@ class ModelDiscoveryService:
except urllib.error.HTTPError as http_err: except urllib.error.HTTPError as http_err:
last_err = self._extract_http_error(http_err) last_err = self._extract_http_error(http_err)
except Exception as exc: except Exception as exc:
last_err = str(exc) last_err = str(exc).strip() or type(exc).__name__
return None, last_err or "Отсутствуют учетные данные для Claude" return None, last_err or "Отсутствуют учетные данные для Claude"
elif prov in ("openrouter",): elif prov in ("openrouter",):
@ -574,7 +581,7 @@ class ModelDiscoveryService:
last_err = self._extract_http_error(http_err) last_err = self._extract_http_error(http_err)
logger.debug("OpenRouter model discovery HTTP error for %s: %s", pid, last_err) logger.debug("OpenRouter model discovery HTTP error for %s: %s", pid, last_err)
except Exception as exc: 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) logger.debug("OpenRouter model discovery failed for %s: %s", pid, exc)
return None, last_err or "Отсутствуют учетные данные для OpenRouter" return None, last_err or "Отсутствуют учетные данные для OpenRouter"
@ -613,7 +620,7 @@ class ModelDiscoveryService:
last_err = self._extract_http_error(http_err) last_err = self._extract_http_error(http_err)
logger.debug("NVIDIA model discovery HTTP error for %s: %s", pid, last_err) logger.debug("NVIDIA model discovery HTTP error for %s: %s", pid, last_err)
except Exception as exc: 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) logger.debug("NVIDIA model discovery failed for %s: %s", pid, exc)
return None, last_err or "Отсутствуют учетные данные для NVIDIA NIM" return None, last_err or "Отсутствуют учетные данные для NVIDIA NIM"
@ -655,7 +662,7 @@ class ModelDiscoveryService:
last_err = self._extract_http_error(http_err) last_err = self._extract_http_error(http_err)
logger.debug("Ollama /api/tags HTTP error on %s: %s", pid, last_err) logger.debug("Ollama /api/tags HTTP error on %s: %s", pid, last_err)
except Exception as exc: 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) logger.debug("Ollama /api/tags query failed on %s: %s", pid, exc)
# 2. Try OpenAI-compatible endpoint /v1/models # 2. Try OpenAI-compatible endpoint /v1/models
@ -675,7 +682,7 @@ class ModelDiscoveryService:
last_err = self._extract_http_error(http_err) last_err = self._extract_http_error(http_err)
logger.debug("Ollama /v1/models HTTP error on %s: %s", pid, last_err) logger.debug("Ollama /v1/models HTTP error on %s: %s", pid, last_err)
except Exception as exc: 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) logger.debug("Ollama /v1/models query failed on %s: %s", pid, exc)
return None, last_err or "Не удалось подключиться к серверу Ollama" return None, last_err or "Не удалось подключиться к серверу Ollama"
@ -708,13 +715,12 @@ class ModelDiscoveryService:
for m in items for m in items
if m if m
] ]
if models: return sorted(set(models)), None
return sorted(set(models)), None
except urllib.error.HTTPError as http_err: except urllib.error.HTTPError as http_err:
last_err = self._extract_http_error(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) logger.debug("Local LLM model query HTTP error on %s (%s): %s", pid, base_url, last_err)
except Exception as exc: 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) logger.debug("Local LLM model query failed on %s (%s): %s", pid, base_url, exc)
return None, last_err or "Не удалось подключиться к локальному серверу LLM" return None, last_err or "Не удалось подключиться к локальному серверу LLM"

View file

@ -75,6 +75,7 @@ class HubStateStore:
def __init__(self) -> None: def __init__(self) -> None:
self._lock = threading.RLock() self._lock = threading.RLock()
self._removed_accounts: set[str] = set()
self._generation: int = 0 self._generation: int = 0
self._current_snapshot: Optional[HubSnapshot] = None self._current_snapshot: Optional[HubSnapshot] = None
self._pending_refreshes: Dict[str, float] = {} self._pending_refreshes: Dict[str, float] = {}
@ -138,6 +139,9 @@ class HubStateStore:
# requests may complete while this build is running. # requests may complete while this build is running.
uh_service = UnifiedHealthService.get() uh_service = UnifiedHealthService.get()
profiles_by_prov = uh_service.scan_all(force=force_scan) 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 = { all_profs = {
profile.profile_id: profile profile.profile_id: profile
for profiles in profiles_by_prov.values() for profiles in profiles_by_prov.values()
@ -386,6 +390,8 @@ class HubStateStore:
profile: ProfileViewModel | str, profile: ProfileViewModel | str,
profile_id: Optional[str] = None, profile_id: Optional[str] = None,
) -> None: ) -> None:
with self._lock:
self._removed_accounts.discard(profile_id if isinstance(profile, str) else profile.profile_id)
if isinstance(profile, str): if isinstance(profile, str):
provider = profile provider = profile
if profile_id is None: 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: def apply_delta_account_removed(self, provider: str, profile_id: str) -> None:
with self._lock: with self._lock:
self._removed_accounts.add(profile_id)
current = self._current_snapshot or self._build_empty_snapshot() current = self._current_snapshot or self._build_empty_snapshot()
all_profiles = dict(current.all_profiles) all_profiles = dict(current.all_profiles)
all_profiles.pop(profile_id, None) all_profiles.pop(profile_id, None)

View file

@ -539,7 +539,7 @@ class UnifiedHealthService:
model_meta = ModelDiscoveryService.get().get_models_with_metadata(prov, pid) model_meta = ModelDiscoveryService.get().get_models_with_metadata(prov, pid)
if prov == "ollama": if prov == "ollama":
model_meta["cloud"] = ModelDiscoveryService.get().get_models_with_metadata("ollama-cloud-catalog") 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": if check.get("state") == "checking":
health_state, health_lbl = "checking", "Проверяется…" health_state, health_lbl = "checking", "Проверяется…"
elif check.get("state") == "failed": elif check.get("state") == "failed":

View file

@ -101,7 +101,10 @@ def get_auth_token(x_hub_token: str = Header(None)) -> bool:
@app.get("/api/health") @app.get("/api/health")
def health_check(): def health_check():
from ..account_probe_service import AccountProbeService
return { return {
"pid": os.getpid(),
"account_probe": AccountProbeService.get().status(),
"ok": True, "ok": True,
"version": __version__, "version": __version__,
"commit": get_installed_commit(), "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") raise HTTPException(status_code=503, detail="Snapshot not ready")
snap_dict = dataclasses.asdict(snapshot) 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) snap_dict = sanitize_snapshot(snap_dict)
server_host = _web_settings().get("web_api_host", "127.0.0.1") 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() 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") 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"): if result.get("unknown"):
raise HTTPException(status_code=404, detail="Неизвестное действие") raise HTTPException(status_code=404, detail="Неизвестное действие")
return { return {
"ok": result.get("ok", False), "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", {}) "data": result.get("data", {})
} }
@ -488,6 +517,7 @@ if _STATIC_DIR.is_dir():
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
_SNAPSHOT_REFRESH_SEC = 30 _SNAPSHOT_REFRESH_SEC = 30
_background_stop = threading.Event()
def _background_refresh_loop() -> None: def _background_refresh_loop() -> None:
@ -509,17 +539,18 @@ def _background_refresh_loop() -> None:
except Exception as exc: except Exception as exc:
logger.debug("Initial background update check skipped: %s", exc) logger.debug("Initial background update check skipped: %s", exc)
while True: while not _background_stop.is_set():
try: try:
AccountProbeService.get().tick() AccountProbeService.get().tick()
HubStateStore.get().refresh(force_scan=False) HubStateStore.get().refresh(force_scan=False)
except Exception as exc: except Exception as exc:
logger.warning("Snapshot refresh failed: %s", exc) logger.warning("Snapshot refresh failed: %s", exc)
time.sleep(_SNAPSHOT_REFRESH_SEC) _background_stop.wait(_SNAPSHOT_REFRESH_SEC)
@app.on_event("startup") @app.on_event("startup")
def _start_background_refresh() -> None: def _start_background_refresh() -> None:
_background_stop.clear()
# В фоне: опрос ходит по сети к нескольким провайдерам, держать на нём # В фоне: опрос ходит по сети к нескольким провайдерам, держать на нём
# старт сервера нельзя. # старт сервера нельзя.
threading.Thread(target=_background_refresh_loop, daemon=True, name="hub-web-refresh").start() 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() AccountQuotaService.get().start_background_scheduler()
except Exception as exc: except Exception as exc:
logger.warning("Could not start quota scheduler: %s", 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 || {}; const profilesByProv = currentSnapshot.profiles_by_provider || {};
let totalProfiles = 0; let totalProfiles = 0;
let visibleProfiles = 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)) { for (const [providerId, profiles] of Object.entries(profilesByProv)) {
if (providerFilter !== 'all' && providerFilter !== providerId) continue; if (providerFilter !== 'all' && providerFilter !== providerId) continue;
@ -846,7 +846,7 @@ function renderAccountCard(profile) {
</div> </div>
</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)} ${renderAccountCheck(profile)}
${quotaGridHtml} ${quotaGridHtml}
</div> </div>
@ -862,8 +862,9 @@ function renderAccountCheck(profile) {
const modelStatus = meta.error ? `Сервер отказал: ${meta.error}` : timestamp ? `Получено ${models.length} моделей · ${timestamp}` : 'Список моделей ещё не получен'; const modelStatus = meta.error ? `Сервер отказал: ${meta.error}` : timestamp ? `Получено ${models.length} моделей · ${timestamp}` : 'Список моделей ещё не получен';
return `<div class="account-check" aria-live="polite"> return `<div class="account-check" aria-live="polite">
${checking ? `<p>${escapeHtml(profile.display_name || profile.profile_id)}: идёт опрос провайдера, это может занять до минуты на этап.</p>` : ''} ${checking ? `<p>${escapeHtml(profile.display_name || profile.profile_id)}: идёт опрос провайдера, это может занять до минуты на этап.</p>` : ''}
<p>${escapeHtml(check.message || "Подключение ещё не проверялось")}</p>
<p>${escapeHtml(modelStatus)}</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>` : ''} ${profile.provider === 'ollama' ? `<p>Выше — модели указанного сервера Ollama.</p><p>Облачный каталог Ollama: ${meta.cloud?.error ? 'Н/Д — ' + escapeHtml(meta.cloud.error) : meta.cloud?.models ? escapeHtml(meta.cloud.models.join(', ')) : 'Н/Д — ещё не получен'}</p><p>Доступ аккаунта к облачным моделям: Н/Д до успешного вызова. Для прямого вызова нужен API-ключ Ollama; для локального клиента — вход через ollama signin.</p>` : ''}
<button class="btn btn-ghost btn-sm" ${checking ? 'disabled' : ''} onclick="event.stopPropagation(); handleAccountProbe('${escapeHtml(profile.profile_id)}')">${checking ? 'Проверяется…' : 'Проверить подключение и модели'}</button> <button class="btn btn-ghost btn-sm" ${checking ? 'disabled' : ''} onclick="event.stopPropagation(); handleAccountProbe('${escapeHtml(profile.profile_id)}')">${checking ? 'Проверяется…' : 'Проверить подключение и модели'}</button>
</div>`; </div>`;
@ -1516,6 +1517,10 @@ function renderHealthView() {
renderHostResources(resContainer, currentSnapshot.metrics?.host || {}); 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); renderHealthPanels(currentSnapshot);
const warningsContainer = document.getElementById('health-warnings-list'); const warningsContainer = document.getElementById('health-warnings-list');
if (warningsContainer) { if (warningsContainer) {
@ -2452,9 +2457,10 @@ async function handleNodeModelChange(roleId, profileId, newModel) {
async function handleRefreshProviderModels(providerId, profileId = null) { async function handleRefreshProviderModels(providerId, profileId = null) {
showToast(`Запрос списка моделей для ${providerId}...`, 'info'); 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) { if (res && res.ok) {
showToast('Запрос обновления моделей отправлен', 'success'); showToast(res.message, 'success');
await fetchSnapshot();
if (profileId) { if (profileId) {
setTimeout(() => openAccountDetailsModal(profileId, true), 500); setTimeout(() => openAccountDetailsModal(profileId, true), 500);
} else { } else {
@ -2680,12 +2686,14 @@ function openAddAccountWizard() {
window._wiz_redirect_slot_id = undefined; window._wiz_redirect_slot_id = undefined;
window._wiz_base_url = undefined; window._wiz_base_url = undefined;
window._wiz_token = undefined; window._wiz_token = undefined;
window._wiz_models = undefined;
if (elements.modalTitle) elements.modalTitle.textContent = 'Мастер подключения учетной записи'; if (elements.modalTitle) elements.modalTitle.textContent = 'Мастер подключения учетной записи';
showWizardStep1(); showWizardStep1();
showModal(); showModal();
} }
function showWizardStep1() { function showWizardStep1() {
window._wiz_models = undefined;
stopDeviceAuthPolling(); stopDeviceAuthPolling();
stopRedirectAuthPolling(); stopRedirectAuthPolling();
for (const key of ['device_profile', 'device_session', 'redirect_session', 'redirect_provider', 'redirect_slot_id', 'base_url', 'token']) window['_wiz_' + key] = undefined; 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_device_profile = undefined;
window._wiz_base_url = undefined; window._wiz_base_url = undefined;
window._wiz_token = undefined; window._wiz_token = undefined;
window._wiz_models = undefined;
} }
window._wiz_provider = providerId; window._wiz_provider = providerId;
let bodyHtml = ''; let bodyHtml = '';
@ -2932,7 +2941,12 @@ function showWizardStep2(providerId) {
elements.modalFooter.innerHTML = footerHtml; 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'); const baseInput = document.getElementById('wiz-base-url-input');
if (baseInput) { if (baseInput) {
window._wiz_base_url = baseInput.value.trim(); window._wiz_base_url = baseInput.value.trim();
@ -2954,7 +2968,21 @@ function proceedToWizardStep3(providerId) {
window._wiz_device_profile = redirectSlot?.value || ''; window._wiz_device_profile = redirectSlot?.value || '';
} }
// For local providers (local, local-llm, llama.cpp, ollama, vllm), do not read any slot elements // 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); showWizardStep3(providerId);
} finally {
window._wiz_validating = false;
if (nextButton) nextButton.disabled = false;
}
} }
function showWizardStep3(providerId) { function showWizardStep3(providerId) {
@ -2988,6 +3016,7 @@ function showWizardStep3(providerId) {
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);"> <div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
Шаг 3 из 3: Назначение роли для нового аккаунта Шаг 3 из 3: Назначение роли для нового аккаунта
</div> </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;"> <div style="margin-bottom:14px;">
<label style="display:block; font-weight:600; margin-bottom:4px;">Целевая роль в роутере:</label> <label style="display:block; font-weight:600; margin-bottom:4px;">Целевая роль в роутере:</label>
<select class="select-filter" style="width:100%;" id="wiz-target-role"> <select class="select-filter" style="width:100%;" id="wiz-target-role">
@ -3033,6 +3062,7 @@ async function finishAddAccount(providerId) {
const payload = { const payload = {
provider: providerId, provider: providerId,
target_role: targetRole, target_role: targetRole,
preferred_model: document.getElementById('wiz-preferred-model')?.value || '',
// GAP-2: передаём выбранный слот, чтобы бэкенд НЕ делал find_free_slot для owner // GAP-2: передаём выбранный слот, чтобы бэкенд НЕ делал find_free_slot для owner
profile_id: selectedProfileId, profile_id: selectedProfileId,
}; };
@ -3048,7 +3078,7 @@ async function finishAddAccount(providerId) {
finally { window._wiz_saving = false; if (finishButton) finishButton.disabled = false; } finally { window._wiz_saving = false; if (finishButton) finishButton.disabled = false; }
if (res && res.ok) { if (res && res.ok) {
showToast('Аккаунт сохранён. Идёт проверка подключения…', 'success'); showToast(res.message, 'success');
closeModal(); closeModal();
fetchSnapshot(); fetchSnapshot();
} else { } 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": elif chosen_asset_name == "HermesHubSetup.exe":
try: try:
cmd = [str(dest_file), "/silent", "/reinstall"] cmd = [str(dest_file), "/silent", "/reinstall", "/restart"]
creation_flags = 0 creation_flags = 0
if hasattr(subprocess, "DETACHED_PROCESS") and hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"): if hasattr(subprocess, "DETACHED_PROCESS") and hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
creation_flags = subprocess.DETACHED_PROCESS | 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() cfg = RouterConfig()
save_router_config(cfg) 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 yield hermes_home
@ -111,7 +118,7 @@ def test_p0_1_add_account_openrouter_default_base_url():
}, },
) )
assert res["ok"] is True assert res["ok"] is True
assert "сохранён" in res["message"] assert "проверено" in res["message"]
# Verify profile created and auth saved # Verify profile created and auth saved
auth = ProfileAuthManager.load_profile_auth("openrouter", "openrouter-1") 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 res["ok"] is True
assert "сохранён" in res["message"] assert "проверено" in res["message"]
auth = ProfileAuthManager.load_profile_auth("nvidia", "nvidia-1") auth = ProfileAuthManager.load_profile_auth("nvidia", "nvidia-1")
assert auth is not None assert auth is not None

View file

@ -66,6 +66,13 @@ def setup_isolated_env(tmp_path, monkeypatch):
UnifiedHealthService._instance = None UnifiedHealthService._instance = None
HubStateStore._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 { yield {
"hermes_home": hermes_home, "hermes_home": hermes_home,
"profiles_yaml": profiles_yaml, "profiles_yaml": profiles_yaml,

View file

@ -22,7 +22,7 @@ def isolated(tmp_path, monkeypatch):
monkeypatch.setattr(AccountProbeService, "_instance", service) monkeypatch.setattr(AccountProbeService, "_instance", service)
discovery = ModelDiscoveryService(tmp_path / "test-model-cache.json") discovery = ModelDiscoveryService(tmp_path / "test-model-cache.json")
monkeypatch.setattr(ModelDiscoveryService, "_instance", discovery) 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) monkeypatch.setattr("antigravity_provider.router.state_store.HubStateStore.refresh", lambda *a, **kw: None)
ActionExecutor._pending_connections.clear() ActionExecutor._pending_connections.clear()
yield service, discovery yield service, discovery
@ -65,16 +65,10 @@ def test_invalid_key_real_http_correct_slot(isolated, provider):
"provider": provider, "token": "intentionally-invalid", "provider": provider, "token": "intentionally-invalid",
"base_url": f"http://127.0.0.1:{server.server_port}/v1", "base_url": f"http://127.0.0.1:{server.server_port}/v1",
}) })
assert result["ok"] assert not result["ok"]
pid = result["data"]["profile_id"] assert "401" in result["message"]
assert pid.startswith(provider + "-") assert not load_router_config().profiles
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"]
finally: finally:
server.shutdown() server.shutdown()
server.server_close() server.server_close()
@ -187,13 +181,9 @@ def test_slow_action_does_not_block_web_health(isolated, monkeypatch):
asyncio.run(exercise()) asyncio.run(exercise())
def test_repeated_connection_rejected_while_checking(isolated, monkeypatch): def test_repeated_invalid_connection_does_not_create_profiles(isolated):
service, _ = isolated # Repeated invalid input cannot consume slots or create placeholder credentials.
service.enabled = True payload = {"provider": "nvidia", "token": "invalid", "base_url": "invalid-url"}
monkeypatch.setattr(service._pool, "submit", lambda *args: None) assert not ActionExecutor.execute("add_account", payload)["ok"]
payload = {"provider": "nvidia", "token": "intentionally-invalid-repeated"} assert not ActionExecutor.execute("add_account", payload)["ok"]
first = ActionExecutor.execute("add_account", payload) assert not load_router_config().profiles
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"]

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 from __future__ import annotations
import json import json
import os
import subprocess
import time import time
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest import pytest
from antigravity_provider.agy_subprocess import ( from antigravity_provider.agy_subprocess import (
check_profile_native_auth_status, check_profile_native_auth_status,
launch_native_agy_login,
) )
from antigravity_provider.paths import get_profile_dir from antigravity_provider.paths import get_profile_dir
from antigravity_provider.router.action_handler import do_set_model 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 @pytest.mark.unit
def test_launch_native_agy_login_env_isolation(tmp_path, monkeypatch): def test_unused_console_login_removed():
"""P0-2: launch_native_agy_login launches agy in profile's isolated directory.""" from antigravity_provider import agy_subprocess
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) assert not hasattr(agy_subprocess, "launch_native_agy_login")
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()
# ── TEST 3: Detection of native agy authentication ── # ── TEST 3: Detection of native agy authentication ──

View file

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