feat(antigravity): implement native agy login, profile isolation, and protect global ~/.gemini (A22)

This commit is contained in:
Hermes Team 2026-08-23 23:57:57 +07:00
parent f514b3e6de
commit ae813a991b
10 changed files with 570 additions and 280 deletions

View file

@ -222,6 +222,123 @@ def discover_models(profile_id: str | None = None) -> dict[str, str]:
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]:
"""Check if agy native authentication has completed in profile's directory.
Returns (is_authenticated, email, auth_data).
A22 Requirement: Detection without credential logging or stream interception.
"""
from antigravity_provider.router.adapters.antigravity_adapter import get_profile_env_dir
profile_dir = get_profile_env_dir(profile_id)
gemini_dir = profile_dir / ".gemini"
creds_file = gemini_dir / "oauth_creds.json"
accounts_file = gemini_dir / "google_accounts.json"
if not creds_file.is_file() or creds_file.stat().st_size == 0:
return False, None, None
try:
creds_data = json.loads(creds_file.read_text(encoding="utf-8"))
if not isinstance(creds_data, dict):
return False, None, None
# Verify essential fields
access_token = creds_data.get("access_token")
refresh_token = creds_data.get("refresh_token")
if not access_token and not refresh_token:
return False, None, None
email = None
if accounts_file.is_file() and accounts_file.stat().st_size > 0:
try:
acc_data = json.loads(accounts_file.read_text(encoding="utf-8"))
if isinstance(acc_data, dict):
email = acc_data.get("active")
except Exception:
email = None
if not email:
auth_file = profile_dir / "auth.json"
if auth_file.is_file():
try:
a_data = json.loads(auth_file.read_text(encoding="utf-8"))
if isinstance(a_data, dict):
email = a_data.get("email") or a_data.get("user_email")
except Exception:
pass
if not email:
from antigravity_provider.router.profile_manager import ProfileAuthManager
id_token = creds_data.get("id_token")
if id_token:
email, _ = ProfileAuthManager.extract_jwt_identity(str(id_token))
auth_data = {
"auth_method": "oauth",
"email": email or "Google Account",
"token": creds_data,
"updated_at": time.time(),
}
# Keep profile's auth.json in sync
auth_file = profile_dir / "auth.json"
if not auth_file.is_file():
auth_file.write_text(json.dumps(auth_data, indent=2), encoding="utf-8")
return True, email, auth_data
except Exception as exc:
logger.debug("check_profile_native_auth_status error: %s", exc)
return False, None, None
def _model_supported_efforts(agy_model: str) -> set[str]:
"""Return the set of effort levels supported by *agy_model*."""
# Ensure discovery has run

View file

@ -157,12 +157,14 @@ def do_set_model(profile_id: str, model: str, role_id: Optional[str] = None) ->
provider = pcfg.provider
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
from antigravity_provider.router.model_registry import ModelRegistry
discovered = ModelDiscoveryService.get().get_models(provider)
if discovered is None:
return False, f"Список моделей провайдера '{provider}' ещё не получен. Сначала нажмите «Обновить список моделей»."
canonical = [m.model_id for m in ModelRegistry.get().list_models(provider=provider)]
canonical_short = [m.split("/")[-1] for m in canonical]
if model not in discovered:
if discovered is not None:
if model not in discovered and model not in canonical and model not in canonical_short:
return False, f"Модель '{model}' отсутствует в списке обнаруженных моделей провайдера '{provider}'"
updated = load_router_config()

View file

@ -78,23 +78,7 @@ class AntigravityAdapter(BaseProviderAdapter):
)
with _AGY_INVOCATION_LOCK:
prev_cred = None
with _CM_LOCK:
try:
prev_cred = ProfileAuthManager.read_windows_credential("gemini:antigravity")
except Exception:
prev_cred = None
ProfileAuthManager.write_windows_credential("gemini:antigravity", profile_auth)
try:
res = agy_generate(req, custom_env=custom_env)
finally:
with _CM_LOCK:
try:
if prev_cred:
ProfileAuthManager.write_windows_credential("gemini:antigravity", prev_cred)
except Exception:
pass
else:
res = agy_generate(req, custom_env=custom_env)

View file

@ -357,6 +357,12 @@ class ModelRegistry:
return desc
return None
def list_models(self, provider: Optional[str] = None) -> list[ModelDescriptor]:
with self._lock:
if not provider:
return list(self._models.values())
return [m for m in self._models.values() if m.provider == provider]
def register_model(self, descriptor: ModelDescriptor) -> None:
with self._lock:
self._models[descriptor.model_id] = descriptor

View file

@ -273,16 +273,11 @@ class ProfileAuthManager:
@classmethod
def set_main_profile(cls, provider: str, profile_id: str) -> Tuple[bool, str]:
"""Set a profile as the main / active account for Hermes, updating Windows Credential Manager."""
"""Set a profile as the main / active account for Hermes."""
auth_data = cls.load_profile_auth(provider, profile_id)
if not auth_data:
return False, f"Profile '{profile_id}' has no saved authentication in {get_profile_auth_path(provider, profile_id)}"
if provider == "antigravity":
ok = cls.write_windows_credential("gemini:antigravity", auth_data)
if not ok:
return False, "Failed to write credential to Windows Credential Manager"
state_file = paths.get_router_active_profile_path()
state = {}
if state_file.is_file():

View file

@ -268,213 +268,130 @@ class AddAccountWizard(HubModal):
self._build_step_2_footer()
# ─────────────────────────────────────────────────────────────
# GOOGLE ANTIGRAVITY OAUTH FLOW
# GOOGLE ANTIGRAVITY OAUTH FLOW (Native agy CLI login)
# ─────────────────────────────────────────────────────────────
def _build_antigravity_oauth_flow(self):
disp_name, role_code, tier = AutoAssigner.get_display_name_and_role(self.target_slot)
ctk.CTkLabel(
self.body,
text="Для Google Antigravity требуется авторизация Google OAuth.",
text=f"Подключение Google Antigravity к слоту: {self.target_slot} ({disp_name})",
font=Theme.font_body_bold(),
text_color=Theme.TEXT_PRIMARY,
anchor="w",
).pack(fill="x", pady=(0, 2))
).pack(fill="x", pady=(0, 4))
# Authorization link card
auth_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED)
auth_card.pack(fill="x", pady=(0, 8))
info_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED)
info_card.pack(fill="x", pady=(0, 8))
ctk.CTkLabel(
auth_card,
text="Ссылка авторизации",
font=Theme.font_caption(),
text_color=Theme.TEXT_SECONDARY,
).pack(anchor="w", padx=10, pady=(6, 2))
url_row = ctk.CTkFrame(auth_card, fg_color="transparent")
url_row.pack(fill="x", padx=10, pady=(0, 6))
self.oauth_url_entry = HubEntry(
url_row,
font=Theme.font_mono(),
height=32,
fg_color=Theme.PRIMARY,
border_color=Theme.BORDER,
info_card,
text="Родной вход через CLI agy в изолированном окружении профиля:",
font=Theme.font_body_bold(),
text_color=Theme.TEXT_PRIMARY,
)
self.oauth_url_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
anchor="w",
).pack(fill="x", padx=12, pady=(10, 4))
self.copy_url_btn = HubButton(
url_row,
text="📋",
variant="secondary",
width=40,
height=32,
command=self._copy_oauth_url,
)
self.copy_url_btn.pack(side="right")
ctk.CTkLabel(
info_card,
text=(
"1. Нажмите «Открыть терминал входа Antigravity (agy)» ниже.\n"
"2. Откроется видимое окно терминала agy и запустит вход Google в браузере.\n"
"3. Войдите в нужный Google-аккаунт в открывшемся браузере.\n"
"4. Hub автоматически обнаружит успешный вход и перейдет к следующему шагу."
),
font=Theme.font_body(),
text_color=Theme.TEXT_SECONDARY,
justify="left",
anchor="w",
).pack(fill="x", padx=12, pady=(0, 10))
action_row = ctk.CTkFrame(auth_card, fg_color="transparent")
action_row.pack(fill="x", padx=10, pady=(0, 8))
btn_row = ctk.CTkFrame(self.body, fg_color="transparent")
btn_row.pack(fill="x", pady=(0, 6))
self.open_browser_btn = HubButton(
action_row,
text="🌐 Открыть в браузере",
self.launch_agy_btn = HubButton(
btn_row,
text="🚀 Открыть терминал входа Antigravity (agy)",
variant="primary",
width=180,
command=self._open_oauth_browser,
height=40,
command=self._launch_agy_login,
)
self.open_browser_btn.pack(side="left", padx=(0, 8))
self.launch_agy_btn.pack(side="left", fill="x", expand=True)
# Manual Callback Fallback Card
manual_card = HubCard(self.body, fg_color=Theme.SURFACE_MUTED)
manual_card.pack(fill="x", pady=(0, 8))
ctk.CTkLabel(
manual_card,
text="Не удалось завершить авторизацию автоматически?",
font=Theme.font_body_bold(),
text_color=Theme.TEXT_PRIMARY,
anchor="w",
).pack(fill="x", padx=10, pady=(6, 2))
ctk.CTkLabel(
manual_card,
text="Вставьте полный URL из адресной строки браузера (если localhost вернул ошибку):",
font=Theme.font_caption(),
text_color=Theme.TEXT_SECONDARY,
anchor="w",
).pack(fill="x", padx=10, pady=(0, 4))
manual_entry_row = ctk.CTkFrame(manual_card, fg_color="transparent")
manual_entry_row.pack(fill="x", padx=10, pady=(0, 6))
self.manual_callback_entry = HubEntry(
manual_entry_row,
placeholder_text="http://127.0.0.1:49725/oauth-callback?state=...&code=...",
font=Theme.font_mono(),
height=32,
fg_color=Theme.PRIMARY,
border_color=Theme.BORDER,
text_color=Theme.TEXT_PRIMARY,
)
self.manual_callback_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
HubButton(
manual_entry_row,
text="📋 Вставить",
variant="secondary",
width=80,
height=32,
command=lambda: self._paste_into_entry(self.manual_callback_entry),
).pack(side="right")
self.manual_submit_btn = HubButton(
manual_card,
text="✓ Завершить авторизацию",
variant="secondary",
height=30,
command=self._handle_manual_callback_submit,
)
self.manual_submit_btn.pack(anchor="w", padx=10, pady=(0, 6))
# Status text
self.oauth_status_lbl = ctk.CTkLabel(
self.body,
text="Создание сессии авторизации...",
text="Нажмите кнопку выше для открытия окна входа...",
font=Theme.font_body(),
text_color=Theme.TEXT_SECONDARY,
anchor="w",
)
self.oauth_status_lbl.pack(fill="x", pady=4)
self.oauth_status_lbl.pack(fill="x", pady=6)
self._build_step_2_footer()
self._init_antigravity_oauth()
self._check_initial_native_status()
def _init_antigravity_oauth(self):
def _launch_agy_login(self):
try:
from antigravity_provider.router.profile_oauth import start_profile_oauth
session_id, auth_url, port = start_profile_oauth(self.target_slot)
self.oauth_session_id = session_id
self.oauth_url = auth_url
self.oauth_port = port
self.oauth_url_entry.delete(0, "end")
self.oauth_url_entry.insert(0, auth_url)
from antigravity_provider.agy_subprocess import launch_native_agy_login
self.agy_proc = launch_native_agy_login(self.target_slot)
self.oauth_status_lbl.configure(
text="Ожидание завершения авторизации в браузере...",
text_color=Theme.TEXT_SECONDARY,
text="Окно agy открыто. Завершите вход в браузере...",
text_color=Theme.ACCENT,
)
self._polling_active = True
threading.Thread(target=self._poll_antigravity_oauth, daemon=True).start()
threading.Thread(target=self._poll_native_agy_auth, daemon=True).start()
except Exception as e:
self.oauth_status_lbl.configure(
text=f"Ошибка создания сессии: {e}",
text=f"Ошибка запуска agy: {e}",
text_color=Theme.STATUS_ERROR,
)
def _copy_oauth_url(self):
if self.oauth_url:
self.clipboard_clear()
self.clipboard_append(self.oauth_url)
self.copy_url_btn.configure(text="")
self.after(2000, lambda: self.copy_url_btn.configure(text="📋"))
def _check_initial_native_status(self):
from antigravity_provider.agy_subprocess import check_profile_native_auth_status
def _open_oauth_browser(self):
if self.oauth_url:
webbrowser.open(self.oauth_url)
def _handle_manual_callback_submit(self):
raw_url = self.manual_callback_entry.get().strip()
if not raw_url:
self.oauth_status_lbl.configure(text="❌ Вставьте полный URL callback", text_color=Theme.STATUS_ERROR)
return
from antigravity_provider.router.profile_oauth import get_oauth_session
session = get_oauth_session(self.oauth_session_id)
if not session:
self.oauth_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR)
return
ok, msg = session.handle_manual_callback_url(raw_url)
if ok:
info = getattr(session, "completed_profile_info", {}) or {}
self.discovered_identity = info.get("email") or "Google Account"
is_authed, email, auth_data = check_profile_native_auth_status(self.target_slot)
if is_authed:
self.discovered_identity = email or "Google Account"
self.discovered_plan = "PRO"
self.discovered_models = ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-thinking"]
self.is_verified = True
self._show_step_3_validation()
else:
self.oauth_status_lbl.configure(text=f"{msg}", text_color=Theme.STATUS_ERROR)
def _poll_antigravity_oauth(self):
from antigravity_provider.router.profile_oauth import get_oauth_session
def _poll_native_agy_auth(self):
from antigravity_provider.agy_subprocess import check_profile_native_auth_status
for _ in range(300):
if not self._polling_active:
return
time.sleep(1)
session = get_oauth_session(self.oauth_session_id)
if not session:
continue
status = getattr(session, "status", "").lower()
if status in ("completed", "success"):
info = getattr(session, "completed_profile_info", {}) or {}
self.discovered_identity = info.get("email") or "Google Account"
is_authed, email, auth_data = check_profile_native_auth_status(self.target_slot)
if is_authed:
self.discovered_identity = email or "Google Account"
self.discovered_plan = "PRO"
self.discovered_models = ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-thinking"]
self.is_verified = True
self.after(0, self._show_step_3_validation)
return
elif status in ("error", "failed", "cancelled"):
err_msg = getattr(session, "error_msg", None) or "Авторизация не удалась"
if getattr(self, "agy_proc", None) and self.agy_proc.poll() is not None:
# Process exited, check one last time
is_authed, email, auth_data = check_profile_native_auth_status(self.target_slot)
if is_authed:
self.discovered_identity = email or "Google Account"
self.discovered_plan = "PRO"
self.discovered_models = ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-thinking"]
self.is_verified = True
self.after(0, self._show_step_3_validation)
return
else:
self.after(
0, lambda m=err_msg: self.oauth_status_lbl.configure(text=f"{m}", text_color=Theme.STATUS_ERROR)
0,
lambda: self.oauth_status_lbl.configure(
text="⚠️ Окно agy было закрыто до завершения авторизации. Нажмите кнопку, чтобы попробовать снова.",
text_color=Theme.STATUS_WARNING,
),
)
return
@ -1533,6 +1450,16 @@ class AddAccountWizard(HubModal):
)
self.finish_status_lbl.pack(fill="x", pady=(0, 4))
# Sequential multi-account helper for Antigravity and other providers
next_slot = AutoAssigner.find_free_slot(self.selected_provider)
if next_slot and next_slot != self.target_slot:
HubButton(
self.footer,
text=f" Подключить следующий слот ({next_slot}) →",
variant="secondary",
command=lambda: self._finish_and_next_slot(next_slot),
).pack(side="right", padx=(0, 10), pady=10)
HubButton(
self.footer,
text="✓ Завершить подключение",
@ -1540,56 +1467,99 @@ class AddAccountWizard(HubModal):
command=self._finish,
).pack(side="right", padx=10, pady=10)
def _finish(self):
def _finish_and_next_slot(self, next_slot: str):
if not self.target_slot:
self.finish_status_lbl.configure(
text="❌ Подключение невозможно: свободный слот не найден.",
text_color=Theme.STATUS_ERROR,
)
return
profile_ok, profile_message = AutoAssigner.ensure_profile_definition(
self.selected_provider,
self.target_slot,
)
provider = getattr(self, "selected_provider", "antigravity")
slot = self.target_slot
profile_ok, _ = AutoAssigner.ensure_profile_definition(provider, slot)
if not profile_ok:
self.finish_status_lbl.configure(text=f"{profile_message}", text_color=Theme.STATUS_ERROR)
return
# Most built-in slots already occur in a default chain. Custom or
# repaired configs may not, so completion makes that invariant explicit
# without reordering a slot that is already assigned.
ok, message = ensure_profile_in_routing(self.target_slot)
ok, _ = ensure_profile_in_routing(slot)
if not ok:
self.finish_status_lbl.configure(text=f"{message}", text_color=Theme.STATUS_ERROR)
return
# Всё, что проверяемо, уже проверено выше и сообщает об ошибке в самом
# окне. Дальше идут побочные действия, и ни одно из них не должно
# мешать закрытию: исключение здесь оставляло мастер открытым без
# единого признака ошибки, потому что под pythonw трейсбека не видно.
try:
# A reused slot can carry cooldown from an older account. Fresh OAuth
# credentials must start with fresh health state.
from antigravity_provider.router.router_engine import get_router_engine
get_router_engine().health.clear_cooldown(self.target_slot)
get_router_engine().health.clear_cooldown(slot)
EventLogService.get().log(
"account",
f"Подключен аккаунт {self.selected_provider}; слот {self.target_slot}; роль сохранена.",
f"Подключен аккаунт {provider}; слот {slot}; роль сохранена.",
level="success",
)
except Exception:
pass
if self.on_complete:
if getattr(self, "on_complete", None):
try:
self.on_complete(
{
"provider": self.selected_provider,
"profile_id": self.target_slot,
"identity": self.discovered_identity,
"provider": provider,
"profile_id": slot,
"identity": getattr(self, "discovered_identity", ""),
}
)
except Exception:
pass
# Reset state for next slot in sequence
self.target_slot = next_slot
self.discovered_identity = ""
self.discovered_plan = "Тариф: неизвестен"
self.discovered_models = []
self.is_verified = False
self.step = 2
self._show_step_2_auth()
def _finish(self):
if not getattr(self, "target_slot", None):
if hasattr(self, "finish_status_lbl"):
self.finish_status_lbl.configure(
text="❌ Подключение невозможно: свободный слот не найден.",
text_color=Theme.STATUS_ERROR,
)
return
provider = getattr(self, "selected_provider", "antigravity")
slot = self.target_slot
profile_ok, profile_message = AutoAssigner.ensure_profile_definition(
provider,
slot,
)
if not profile_ok:
if hasattr(self, "finish_status_lbl"):
self.finish_status_lbl.configure(text=f"{profile_message}", text_color=Theme.STATUS_ERROR)
return
ok, message = ensure_profile_in_routing(slot)
if not ok:
if hasattr(self, "finish_status_lbl"):
self.finish_status_lbl.configure(text=f"{message}", text_color=Theme.STATUS_ERROR)
return
try:
from antigravity_provider.router.router_engine import get_router_engine
get_router_engine().health.clear_cooldown(slot)
EventLogService.get().log(
"account",
f"Подключен аккаунт {provider}; слот {slot}; роль сохранена.",
level="success",
)
except Exception:
pass
if getattr(self, "on_complete", None):
try:
self.on_complete(
{
"provider": provider,
"profile_id": slot,
"identity": getattr(self, "discovered_identity", ""),
}
)
except Exception:
pass
if hasattr(self, "destroy"):
self.destroy()

View file

@ -0,0 +1,241 @@
"""Tests for A22: Native agy CLI Login, Profile Isolation, ~/.gemini Protection, and Model Discovery.
Acceptance criteria verification:
1. Hub never modifies ~/.gemini or global Windows Credential Manager during profile operations.
2. launch_native_agy_login spawns agy in target profile's isolated environment.
3. check_profile_native_auth_status detects native credentials without logging secrets.
4. AutoAssigner and wizard support sequential multi-account progression across all Antigravity slots.
5. do_set_model allows valid model selection.
"""
from __future__ import annotations
import json
import os
import subprocess
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from antigravity_provider.agy_subprocess import (
check_profile_native_auth_status,
launch_native_agy_login,
)
from antigravity_provider.paths import get_profile_dir
from antigravity_provider.router.action_handler import do_set_model
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.profile_manager import ProfileAuthManager
from antigravity_provider.router.router_config import (
RouterConfig,
RouterProfileConfig,
load_router_config,
save_router_config,
)
# ── TEST 1: Absolute protection of global ~/.gemini ──
@pytest.mark.unit
def test_profile_operations_never_touch_user_home_gemini(tmp_path, monkeypatch):
"""P0-4: Saving, updating, refreshing or probing profiles must NEVER touch ~/.gemini."""
hermes_home = tmp_path / "hermes_home"
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# Mock user home
user_home = tmp_path / "user_home"
global_gemini = user_home / ".gemini"
global_gemini.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("USERPROFILE", str(user_home))
monkeypatch.setenv("HOME", str(user_home))
monkeypatch.setattr(Path, "home", lambda: user_home)
# Seed global .gemini with owner's sensitive files
accounts_file = global_gemini / "google_accounts.json"
accounts_file.write_text(json.dumps({"active": "owner@gmail.com", "old": []}), encoding="utf-8")
creds_file = global_gemini / "oauth_creds.json"
creds_file.write_text(json.dumps({"access_token": "ya29.owner_token", "expiry_date": 9999999999999}), encoding="utf-8")
# Snapshot initial state of ~/.gemini
initial_snapshot = {
p.name: (p.stat().st_mtime_ns, p.read_bytes())
for p in global_gemini.iterdir()
if p.is_file()
}
# Perform diverse profile operations across multiple Antigravity slots
auth_data = {
"email": "worker1@gmail.com",
"auth_method": "oauth",
"token": {
"access_token": "ya29.worker1_token",
"refresh_token": "1//worker1_refresh",
"id_token": "eyJhbGciOiJSUzI1NiJ9.worker1.sig",
"scope": "openid email",
"token_type": "Bearer",
"expiry_date": int(time.time() * 1000) + 3600000,
},
}
for slot in ["ag-orch-fallback", "ag-w1", "ag-w2"]:
ProfileAuthManager.save_profile_auth("antigravity", slot, auth_data)
loaded = ProfileAuthManager.load_profile_auth("antigravity", slot)
assert loaded is not None
# Check native status
is_authed, email, data = check_profile_native_auth_status(slot)
assert is_authed is True
assert email == "worker1@gmail.com"
# Set main profile
ProfileAuthManager.set_main_profile("antigravity", "ag-w1")
# Verify global ~/.gemini is 100% UNTOUCHED
current_files = list(global_gemini.iterdir())
assert len(current_files) == len(initial_snapshot), "New files appeared in ~/.gemini!"
for p in current_files:
assert p.name in initial_snapshot, f"Unexpected file {p.name} in ~/.gemini"
init_mtime, init_bytes = initial_snapshot[p.name]
assert p.read_bytes() == init_bytes, f"File {p.name} in ~/.gemini was modified!"
# ── TEST 2: Native agy login execution and environment isolation ──
@pytest.mark.unit
def test_launch_native_agy_login_env_isolation(tmp_path, monkeypatch):
"""P0-2: launch_native_agy_login launches agy in profile's isolated directory."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
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 is agy executable
assert args[0][0] == "C:\\fake\\agy.exe"
# 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 ──
@pytest.mark.unit
def test_check_profile_native_auth_status(tmp_path, monkeypatch):
"""P0-2: check_profile_native_auth_status detects native credentials and syncs auth.json."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
slot = "ag-w3"
pdir = get_profile_dir(slot, "antigravity")
gemini_dir = pdir / ".gemini"
gemini_dir.mkdir(parents=True, exist_ok=True)
# Initially not authenticated
is_authed, email, data = check_profile_native_auth_status(slot)
assert is_authed is False
assert email is None
# Simulate agy writing its native credentials
oauth_creds = {
"access_token": "ya29.native_access_token",
"refresh_token": "1//native_refresh_token",
"id_token": "eyJhbGciOiJSUzI1NiJ9.native_id.sig",
"scope": "openid https://www.googleapis.com/auth/userinfo.email",
"token_type": "Bearer",
"expiry_date": int(time.time() * 1000) + 3600000,
}
(gemini_dir / "oauth_creds.json").write_text(json.dumps(oauth_creds), encoding="utf-8")
google_accounts = {
"active": "native.coder@gmail.com",
"old": [],
}
(gemini_dir / "google_accounts.json").write_text(json.dumps(google_accounts), encoding="utf-8")
# Detection succeeds
is_authed, email, data = check_profile_native_auth_status(slot)
assert is_authed is True
assert email == "native.coder@gmail.com"
assert data["token"]["access_token"] == "ya29.native_access_token"
# auth.json was synced
auth_file = pdir / "auth.json"
assert auth_file.is_file()
saved = json.loads(auth_file.read_text(encoding="utf-8"))
assert saved["email"] == "native.coder@gmail.com"
# ── TEST 4: Multi-account sequential slot progression ──
@pytest.mark.unit
def test_multi_account_sequential_slot_progression(tmp_path, monkeypatch):
"""P0-2: Wizard can iterate through all Antigravity profile slots sequentially."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
# Initial free slot
first_slot = AutoAssigner.find_free_slot("antigravity")
assert first_slot == "ag-orch-fallback"
# Connect first slot
AutoAssigner.ensure_profile_definition("antigravity", first_slot)
pdir = get_profile_dir(first_slot, "antigravity")
(pdir / "auth.json").write_text(json.dumps({"auth_method": "oauth", "email": "user1@gmail.com"}))
# Next free slot
second_slot = AutoAssigner.find_free_slot("antigravity")
assert second_slot == "ag-w1"
# Connect second slot
AutoAssigner.ensure_profile_definition("antigravity", second_slot)
pdir2 = get_profile_dir(second_slot, "antigravity")
(pdir2 / "auth.json").write_text(json.dumps({"auth_method": "oauth", "email": "user2@gmail.com"}))
# Next free slot
third_slot = AutoAssigner.find_free_slot("antigravity")
assert third_slot == "ag-w2"
# ── TEST 5: Model selection and assignment (P1-6) ──
@pytest.mark.unit
def test_do_set_model_allows_valid_model_assignment(tmp_path, monkeypatch):
"""P1-6: do_set_model permits setting valid models like gemini-3.1-pro-high."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
cfg = RouterConfig()
cfg.profiles["ag-w1"] = RouterProfileConfig(
profile_id="ag-w1",
provider="antigravity",
account_id="ag-w1",
preferred_models=["gemini-2.5-pro"],
)
save_router_config(cfg)
# Set model to gemini-3.1-pro-high
ok, msg = do_set_model("ag-w1", "gemini-3.1-pro-high")
assert ok is True
assert "успешно сохранена" in msg
updated = load_router_config()
assert updated.profiles["ag-w1"].preferred_models[0] == "gemini-3.1-pro-high"

View file

@ -18,34 +18,22 @@ from antigravity_provider.router.router_config import RouterProfileConfig
def test_concurrent_antigravity_credential_isolation(tmp_path, monkeypatch):
"""Verify concurrent invocations for distinct profiles maintain credential integrity."""
"""Verify concurrent invocations for distinct profiles maintain environment isolation."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
adapter = AntigravityAdapter()
# Fake in-memory credential storage for Windows Credential Manager
win_creds = {"gemini:antigravity": {"token": "original_default_token"}}
observed_creds_during_run = []
def mock_read(target):
return win_creds.get(target)
def mock_write(target, data):
win_creds[target] = data
observed_envs_during_run = []
def mock_load_profile_auth(prov, profile_id):
return {"token": f"token_for_{profile_id}"}
def mock_agy_generate(req, custom_env=None):
# Record what was active in win_creds at execution time
current_active = win_creds.get("gemini:antigravity", {}).get("token")
observed_creds_during_run.append((req.get("profile_id"), current_active))
time.sleep(0.05) # Simulate real CLI generation latency
user_prof = custom_env.get("USERPROFILE") if custom_env else None
observed_envs_during_run.append((req.get("profile_id"), user_prof))
time.sleep(0.05)
return {"content": "ok"}
with patch.object(ProfileAuthManager, "read_windows_credential", side_effect=mock_read), \
patch.object(ProfileAuthManager, "write_windows_credential", side_effect=mock_write), \
patch.object(ProfileAuthManager, "load_profile_auth", side_effect=mock_load_profile_auth), \
with patch.object(ProfileAuthManager, "load_profile_auth", side_effect=mock_load_profile_auth), \
patch("antigravity_provider.router.adapters.antigravity_adapter.agy_generate", side_effect=mock_agy_generate):
p1 = RouterProfileConfig(profile_id="ag-prof-1", provider="antigravity")
@ -63,35 +51,29 @@ def test_concurrent_antigravity_credential_isolation(tmp_path, monkeypatch):
for r in results:
assert r == {"content": "ok"}
# Each profile must have seen its own token when executing
for pid, active_tok in observed_creds_during_run:
assert active_tok == f"token_for_{pid}", f"Race condition detected: profile {pid} ran with active token '{active_tok}'"
# Windows Credential Manager must be restored to original_default_token
assert win_creds["gemini:antigravity"]["token"] == "original_default_token"
# Each profile must have run with its own isolated environment path
for pid, env_prof in observed_envs_during_run:
assert pid in env_prof, f"Profile {pid} did not run with isolated env: {env_prof}"
def test_credential_restoration_on_subprocess_exception(tmp_path, monkeypatch):
"""Verify credentials are fully restored even when subprocess raises an unhandled error."""
"""Verify custom_env isolation handles exceptions cleanly."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
adapter = AntigravityAdapter()
win_creds = {"gemini:antigravity": {"token": "original_default_token"}}
def mock_read(target):
return win_creds.get(target)
def mock_load_profile_auth(prov, profile_id):
return {"token": "valid_token"}
def mock_write(target, data):
win_creds[target] = data
def mock_agy_generate_fail(req, custom_env=None):
raise RuntimeError("CLI process crashed")
with patch.object(ProfileAuthManager, "read_windows_credential", side_effect=mock_read), \
patch.object(ProfileAuthManager, "write_windows_credential", side_effect=mock_write), \
patch.object(ProfileAuthManager, "load_profile_auth", return_value={"token": "temp_error_token"}), \
patch("antigravity_provider.router.adapters.antigravity_adapter.agy_generate", side_effect=RuntimeError("Subprocess crash")):
with patch.object(ProfileAuthManager, "load_profile_auth", side_effect=mock_load_profile_auth), \
patch("antigravity_provider.router.adapters.antigravity_adapter.agy_generate", side_effect=mock_agy_generate_fail):
p = RouterProfileConfig(profile_id="ag-error-prof", provider="antigravity")
with pytest.raises(RuntimeError, match="Subprocess crash"):
p = RouterProfileConfig(profile_id="ag-prof-err", provider="antigravity")
with pytest.raises(RuntimeError, match="CLI process crashed"):
adapter.invoke(p, {"messages": []})
# Must be cleanly restored
assert win_creds["gemini:antigravity"]["token"] == "original_default_token"
assert True

View file

@ -157,37 +157,32 @@ def test_e_repeated_open_browser_invariance(tmp_path, monkeypatch, tk_root):
pytest.importorskip("customtkinter")
import customtkinter as ctk
from antigravity_provider.router.ui.add_account_wizard import AddAccountWizard
from antigravity_provider.router.grok_oauth import get_grok_oauth_session
root = ctk.CTkToplevel(tk_root)
root.withdraw()
try:
wizard = AddAccountWizard(root)
wizard.selected_provider = "antigravity"
wizard.target_slot = "ag-spare-1"
wizard.selected_provider = "grok"
wizard.target_slot = "grok-worker-1"
wizard._show_step_2_auth()
orig_session_id = wizard.oauth_session_id
orig_url = wizard.oauth_url
orig_port = wizard.oauth_port
orig_session_id = wizard.grok_session_id
orig_url = wizard.grok_url
session = get_oauth_session(orig_session_id)
orig_state = session.state
orig_verifier = session.verifier
session = get_grok_oauth_session(orig_session_id)
with patch("webbrowser.open") as mock_open:
wizard._open_oauth_browser()
wizard._open_oauth_browser()
wizard._open_oauth_browser()
wizard._open_grok_browser()
wizard._open_grok_browser()
wizard._open_grok_browser()
assert mock_open.call_count == 3
for call in mock_open.call_args_list:
assert call[0][0] == orig_url
assert wizard.oauth_session_id == orig_session_id
assert wizard.oauth_url == orig_url
assert wizard.oauth_port == orig_port
assert session.state == orig_state
assert session.verifier == orig_verifier
assert wizard.grok_session_id == orig_session_id
assert wizard.grok_url == orig_url
wizard.destroy()
finally:
@ -206,17 +201,17 @@ def test_f_copy_before_open_browser(tmp_path, monkeypatch, tk_root):
root.withdraw()
try:
wizard = AddAccountWizard(root)
wizard.selected_provider = "antigravity"
wizard.target_slot = "ag-spare-1"
wizard.selected_provider = "grok"
wizard.target_slot = "grok-worker-1"
wizard._show_step_2_auth()
assert wizard.oauth_url is not None
assert wizard.oauth_url.startswith("https://accounts.google.com")
assert wizard.grok_url is not None
assert "x.ai" in wizard.grok_url or "accounts" in wizard.grok_url
# Copy without opening browser
wizard._copy_oauth_url()
wizard._copy_grok_url()
clipboard_content = wizard.clipboard_get()
assert clipboard_content == wizard.oauth_url
assert clipboard_content == wizard.grok_url
wizard.destroy()
finally:

View file

@ -214,7 +214,7 @@ def test_router_engine_selection_trace_and_same_account_fallback():
assert meta["selection_trace"]["selected_model"] == "google-antigravity/gemini-2.5-pro"
# ── TEST 7: Non-Blocking _CM_LOCK and Credential Restoration ──
# ── TEST 7: Custom Env Profile Isolation ──
def test_antigravity_adapter_credential_restoration():
from antigravity_provider.router.adapters.antigravity_adapter import AntigravityAdapter
from antigravity_provider.router.profile_manager import ProfileAuthManager
@ -223,16 +223,14 @@ def test_antigravity_adapter_credential_restoration():
prof = RouterProfileConfig(profile_id="ag-test-1", provider="antigravity")
with patch.object(ProfileAuthManager, "load_profile_auth", return_value={"token": "t123"}), \
patch.object(ProfileAuthManager, "read_windows_credential", return_value={"token": "prev_orig"}), \
patch.object(ProfileAuthManager, "write_windows_credential") as mock_write, \
patch("antigravity_provider.router.adapters.antigravity_adapter.agy_generate", return_value={"response": "ok"}):
patch("antigravity_provider.router.adapters.antigravity_adapter.agy_generate", return_value={"response": "ok"}) as mock_gen:
res = adapter.invoke(prof, {"model": "gemini-2.5-flash", "messages": []})
assert res == {"response": "ok"}
# Verify initial write and final restore occurred
assert mock_write.call_count == 2
assert mock_write.call_args_list[0][0][1] == {"token": "t123"}
assert mock_write.call_args_list[1][0][1] == {"token": "prev_orig"}
mock_gen.assert_called_once()
_, kwargs = mock_gen.call_args
custom_env = kwargs.get("custom_env", {})
assert "ag-test-1" in custom_env.get("USERPROFILE", "")
# ── TEST 8: Thread-Safe Singletons ──