diff --git a/config/compatibility.json b/config/compatibility.json index 6708556..9882ae5 100644 --- a/config/compatibility.json +++ b/config/compatibility.json @@ -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": [ diff --git a/installer/HermesHubSetup.cs b/installer/HermesHubSetup.cs index 4d8328b..c2b70f1 100644 --- a/installer/HermesHubSetup.cs +++ b/installer/HermesHubSetup.cs @@ -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", то есть манифест сообщал неправду о том, из // какого кода собран установщик. diff --git a/installer/install-linux.sh b/installer/install-linux.sh index 957f848..b77c45c 100644 --- a/installer/install-linux.sh +++ b/installer/install-linux.sh @@ -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}" diff --git a/pyproject.toml b/pyproject.toml index 0866d13..cd7f617 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/src/antigravity_provider/router/profile_manager.py b/src/antigravity_provider/router/profile_manager.py index 912a268..c6bebee 100644 --- a/src/antigravity_provider/router/profile_manager.py +++ b/src/antigravity_provider/router/profile_manager.py @@ -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 /.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 diff --git a/src/antigravity_provider/router/profile_oauth.py b/src/antigravity_provider/router/profile_oauth.py index 59dfa0e..e57bb9f 100644 --- a/src/antigravity_provider/router/profile_oauth.py +++ b/src/antigravity_provider/router/profile_oauth.py @@ -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 = { diff --git a/src/antigravity_provider/version.py b/src/antigravity_provider/version.py index bbec965..96e4912 100644 --- a/src/antigravity_provider/version.py +++ b/src/antigravity_provider/version.py @@ -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" diff --git a/tests/test_a55_account_connection.py b/tests/test_a55_account_connection.py index 45dc260..09c1ad0 100644 --- a/tests/test_a55_account_connection.py +++ b/tests/test_a55_account_connection.py @@ -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 ── diff --git a/tests/test_agy_cli_token_file.py b/tests/test_agy_cli_token_file.py new file mode 100644 index 0000000..679557a --- /dev/null +++ b/tests/test_agy_cli_token_file.py @@ -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()