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", "min_hermes_version": "0.20.0",
"max_tested_hermes_version": "0.20.4", "max_tested_hermes_version": "0.20.4",
"tested_versions": [ "tested_versions": [

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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()