fix(antigravity): вход завершался успешно, а agy требовал войти снова

agy 2.0 читает учётные данные не из .gemini/oauth_creds.json — это формат
Gemini CLI. Свой токен он берёт из .gemini/antigravity-cli/antigravity-oauth-token
в виде {"auth_method": ..., "token": {...}}. Хаб этот файл никогда не создавал,
поэтому «Авторизация успешно завершена» соседствовала с ответом
«Please sign in to view available models».

Установлено сравнением рабочего профиля владельца с неработающим: оба имели
oauth_creds.json одинакового размера, различие было только в этом файле.
Значение auth_method («consumer») взято из рабочего профиля, а не выведено
из общих соображений.

Срок годности пишется в обоих видах: expiry_date в миллисекундах для формата
Node и expiry строкой RFC3339 для Go-шного oauth2.Token.

Заодно устранён рост номеров профилей: слот выбирался до входа, когда почта
ещё неизвестна, и повторный вход тем же аккаунтом занимал очередной свободный
слот — один аккаунт владельца расползся на ag-2, ag-3, ag-4. Теперь после
опознания почты учётные данные возвращаются в слот, который этот аккаунт уже
занимает. Функция проверки двойников в проекте была, но её никто не вызывал.

Версия поднята до 0.1.3, чтобы отличать сборки на глаз. Проверка версии в
тестах сверяется с исходником, а не с записанным числом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-09-01 10:37:27 +07:00
parent 6af388a5dc
commit 9641957d5b
9 changed files with 210 additions and 15 deletions

View file

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

View file

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

View file

@ -7,7 +7,7 @@
set -e
HUB_VERSION="0.1.2"
HUB_VERSION="0.1.3"
DEFAULT_HERMES_HOME="$HOME/.hermes"
HERMES_HOME="${HERMES_HOME:-$DEFAULT_HERMES_HOME}"

View file

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

View file

@ -15,7 +15,7 @@ import threading
import time
import urllib.request
import urllib.error
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
@ -114,6 +114,76 @@ class ProfileAuthManager:
"""Official API to get isolated directory for a profile."""
return get_profile_dir(profile_id, provider)
# agy 2.0 (Antigravity CLI) читает вход НЕ из .gemini/oauth_creds.json —
# это формат Gemini CLI. Свой токен он держит в
# .gemini/antigravity-cli/antigravity-oauth-token, и структура там другая:
# обёртка {"auth_method": ..., "token": {...}}.
#
# Хаб писал только файл Gemini CLI, поэтому авторизация проходила успешно, а
# agy отвечал «Please sign in to view available models»: он смотрел в файл,
# которого нет. Установлено сравнением рабочего профиля с неработающим.
# Значение взято из рабочего профиля владельца, не выведено из общих
# соображений: agy пишет туда "consumer" для личного аккаунта Google.
ANTIGRAVITY_AUTH_METHOD = "consumer"
@classmethod
def _write_antigravity_cli_token(cls, profile_dir: Path, creds_dict: dict) -> Optional[Path]:
"""Записать токен в формате Antigravity CLI рядом с файлом Gemini CLI."""
cli_dir = profile_dir / ".gemini" / "antigravity-cli"
target = cli_dir / "antigravity-oauth-token"
try:
cli_dir.mkdir(parents=True, exist_ok=True)
try:
os.chmod(cli_dir, 0o700)
except OSError:
pass
# Способ входа сохраняем такой же, как у уже работающего профиля на
# этой машине: угадывать его значение нельзя, а рабочий образец
# рядом — самый надёжный источник.
auth_method = cls.ANTIGRAVITY_AUTH_METHOD
try:
for sibling in profile_dir.parent.iterdir():
if sibling == profile_dir or not sibling.is_dir():
continue
ref = sibling / ".gemini" / "antigravity-cli" / "antigravity-oauth-token"
if ref.is_file():
existing = json.loads(ref.read_text(encoding="utf-8"))
if isinstance(existing, dict) and existing.get("auth_method"):
auth_method = str(existing["auth_method"])
break
except Exception:
pass
# Срок годности пишем в обоих видах. Gemini CLI (Node) ждёт
# expiry_date в миллисекундах, Go-шный oauth2.Token — expiry
# строкой RFC3339. Какой из них читает agy, по бинарнику не
# определить, а лишнее поле разбор JSON пропускает.
token_payload = dict(creds_dict)
try:
expiry_ms = int(creds_dict.get("expiry_date") or 0)
if expiry_ms > 0:
token_payload["expiry"] = (
datetime.fromtimestamp(expiry_ms / 1000, tz=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
)
except (TypeError, ValueError, OSError, OverflowError):
pass
payload = {"auth_method": auth_method, "token": token_payload}
temp = cli_dir / f"antigravity-oauth-token.tmp-{threading.get_ident()}-{time.time_ns()}"
temp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
os.replace(temp, target)
try:
os.chmod(target, 0o600)
except OSError:
pass
return target
except Exception as exc:
logger.error("Не записан токен Antigravity CLI в %s: %s", target, exc)
raise
@classmethod
def write_agy_oauth_creds(cls, profile_dir: Path, auth_data: dict) -> Path:
"""Atomically write <profile_dir>/.gemini/oauth_creds.json in exact agy CLI format."""
@ -187,6 +257,8 @@ class ProfileAuthManager:
os.chmod(target_file, 0o600)
except OSError:
pass
cls._write_antigravity_cli_token(profile_dir, creds_dict)
return target_file
@staticmethod

View file

@ -255,6 +255,29 @@ class ProfileOAuthSession:
email = fetch_user_email(tokens["access_token"])
logger.info("OAuth account identity resolved (email_found=%s)", bool(email))
# Слот выбирается ДО входа, когда почта ещё неизвестна, поэтому
# повторный вход тем же аккаунтом занимал очередной свободный
# слот: у владельца один аккаунт расползся на ag-2, ag-3, ag-4.
# Узнав почту, возвращаем учётные данные в слот, который этот
# аккаунт уже занимает, вместо создания двойника.
if email:
from antigravity_provider.router.auto_assigner import AutoAssigner
try:
existing = AutoAssigner.check_duplicate_identity(
"antigravity", email, exclude_profile_id=self.profile_id
)
except Exception as exc:
existing = None
logger.warning("Проверка двойников не выполнена: %s", exc)
if existing and existing != self.profile_id:
logger.info(
"Аккаунт уже занимает профиль %s — пишем туда, а не в %s",
existing,
self.profile_id,
)
self.profile_id = existing
# Format in standard gemini:antigravity shape
expires_at = tokens.get("expires_at") or (int(time.time()) + 3600)
auth_data = {

View file

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

View file

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

View file

@ -0,0 +1,96 @@
"""agy 2.0 читает вход не из oauth_creds.json.
Сравнение рабочего профиля владельца с неработающим показало единственное
значимое различие: у рабочего есть `.gemini/antigravity-cli/antigravity-oauth-token`,
у неработающего только `.gemini/oauth_creds.json`. Оба файла с токенами были
на месте и одинакового размера, но `agy models` отвечал «Please sign in to view
available models».
Формат подтверждён на машине владельца: верхний уровень ключи `auth_method`
и `token`, значение `auth_method` `consumer`.
"""
from __future__ import annotations
import json
import time
from antigravity_provider.router.profile_manager import ProfileAuthManager
def _write(tmp_path):
return ProfileAuthManager.write_agy_oauth_creds(
tmp_path,
{
"access_token": "ya29.TEST-ACCESS",
"refresh_token": "1//TEST-REFRESH",
"scope": "https://www.googleapis.com/auth/cloud-platform",
"token_type": "Bearer",
"id_token": "eyJTEST",
"expiry_date": int((time.time() + 3600) * 1000),
},
)
def test_antigravity_cli_token_written(tmp_path):
_write(tmp_path)
target = tmp_path / ".gemini" / "antigravity-cli" / "antigravity-oauth-token"
assert target.is_file(), "agy читает вход именно отсюда"
payload = json.loads(target.read_text(encoding="utf-8"))
assert sorted(payload) == ["auth_method", "token"]
assert payload["auth_method"] == "consumer"
assert payload["token"]["access_token"] == "ya29.TEST-ACCESS"
assert payload["token"]["refresh_token"] == "1//TEST-REFRESH"
def test_expiry_written_in_both_formats(tmp_path):
_write(tmp_path)
target = tmp_path / ".gemini" / "antigravity-cli" / "antigravity-oauth-token"
token = json.loads(target.read_text(encoding="utf-8"))["token"]
# Node-формат — миллисекунды, Go-шный oauth2.Token — строка RFC3339.
assert isinstance(token["expiry_date"], int)
assert token["expiry"].endswith("Z")
def test_gemini_creds_still_written(tmp_path):
"""Прежний файл остаётся: его читают другие части agy и сам хаб."""
target_file = _write(tmp_path)
assert target_file == tmp_path / ".gemini" / "oauth_creds.json"
creds = json.loads(target_file.read_text(encoding="utf-8"))
assert creds["access_token"] == "ya29.TEST-ACCESS"
assert "auth_method" not in creds
def test_auth_method_taken_from_working_neighbour(tmp_path):
"""Значение способа входа берётся у уже работающего профиля, если он есть."""
neighbour = tmp_path / "ag-working" / ".gemini" / "antigravity-cli"
neighbour.mkdir(parents=True)
(neighbour / "antigravity-oauth-token").write_text(
json.dumps({"auth_method": "workforce", "token": {}}), encoding="utf-8"
)
profile = tmp_path / "ag-new"
profile.mkdir()
_write(profile)
payload = json.loads(
(profile / ".gemini" / "antigravity-cli" / "antigravity-oauth-token").read_text(
encoding="utf-8"
)
)
assert payload["auth_method"] == "workforce"
def test_empty_login_writes_nothing(tmp_path):
"""Вход без токена не должен оставлять ни одного файла учётных данных."""
try:
ProfileAuthManager.write_agy_oauth_creds(tmp_path, {"email": "x@y.z"})
except ValueError:
pass
else: # pragma: no cover - защита от возврата прежнего поведения
raise AssertionError("пустой вход обязан отказывать")
assert not (tmp_path / ".gemini" / "oauth_creds.json").exists()
assert not (
tmp_path / ".gemini" / "antigravity-cli" / "antigravity-oauth-token"
).exists()