feat(antigravity): implement native agy login, profile isolation, and protect global ~/.gemini (A22)
This commit is contained in:
parent
f514b3e6de
commit
ae813a991b
10 changed files with 570 additions and 280 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -157,13 +157,15 @@ 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:
|
||||
return False, f"Модель '{model}' отсутствует в списке обнаруженных моделей провайдера '{provider}'"
|
||||
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()
|
||||
target = updated.profiles[profile_id]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
res = agy_generate(req, custom_env=custom_env)
|
||||
else:
|
||||
res = agy_generate(req, custom_env=custom_env)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -268,215 +268,132 @@ 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 "Авторизация не удалась"
|
||||
self.after(
|
||||
0, lambda m=err_msg: self.oauth_status_lbl.configure(text=f"❌ {m}", text_color=Theme.STATUS_ERROR)
|
||||
)
|
||||
return
|
||||
|
||||
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: self.oauth_status_lbl.configure(
|
||||
text="⚠️ Окно agy было закрыто до завершения авторизации. Нажмите кнопку, чтобы попробовать снова.",
|
||||
text_color=Theme.STATUS_WARNING,
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# OPENAI CODEX OAUTH FLOW
|
||||
|
|
@ -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
|
||||
|
||||
self.destroy()
|
||||
# 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()
|
||||
|
|
|
|||
241
tests/test_agy_native_login.py
Normal file
241
tests/test_agy_native_login.py
Normal 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
|
|
|||
Loading…
Reference in a new issue