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:
parent
6af388a5dc
commit
9641957d5b
9 changed files with 210 additions and 15 deletions
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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", то есть манифест сообщал неправду о том, из
|
||||
// какого кода собран установщик.
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
|
|
|||
96
tests/test_agy_cli_token_file.py
Normal file
96
tests/test_agy_cli_token_file.py
Normal 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()
|
||||
Loading…
Reference in a new issue