hermes-hub/tests/test_a57_agy_native_login.py
ochenstarik-ui 3b423ca351 feat(antigravity): нативный вход через agy в терминале (A57)
- Реализован запуск agy в терминале с изолированным HOME (x-terminal-emulator, gnome-terminal, konsole, xfce4-terminal, tilix, alacritty, kitty, terminator, urxvt, foot, xterm / Windows wt, cmd)
- Опрос появления antigravity-oauth-token вместо ожидания процесса терминала
- Защита занятого слота от перезаписи без подтверждения
- Честное извлечение email без выдумывания identity и предотвращение дубликатов слотов
- Сохранён браузерный OAuth в качестве запасного пути
- Добавлены 12 тестов в test_a57_agy_native_login.py, 640 тестов проходят
2026-09-01 14:25:13 +07:00

384 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for Task A57: Antigravity Native Login via agy CLI.
Covers:
- P0-1: Native agy CLI invocation in terminal with isolated HOME/USERPROFILE/HOMEPATH.
Terminal lookup on Linux and Windows with honest error reporting when missing.
Waiting for credentials by file detection rather than process exit code.
- P0-2: Profile slot is pre-selected and isolated. Occupied slots require confirmation.
Existing account directories in ~/.hermes/agy_profiles/ are never touched.
- P0-3: Account identity is truthfully read from agy-generated files without inventing emails.
AutoAssigner.check_duplicate_identity de-duplicates accounts and prevents slot creep.
- P0-4: Browser OAuth redirect path is preserved as fallback.
- P0-5: Failures report exact cause (including stderr, checked candidate terminals, and HOME).
- P0-6: Security: no credentials in logs, directory permissions 0700, file permissions 0600.
"""
from __future__ import annotations
import json
import os
import shutil
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from antigravity_provider.agy_subprocess import (
check_profile_native_auth_status,
find_terminal_emulator,
poll_native_agy_login,
start_native_agy_login,
)
from antigravity_provider.paths import get_profile_dir
from antigravity_provider.router.action_handler import ActionExecutor
from antigravity_provider.router.router_config import (
RouterConfig,
RouterProfileConfig,
save_router_config,
)
@pytest.fixture(autouse=True)
def isolated_hermes_env(tmp_path, monkeypatch):
"""Ensure every test runs with fully isolated HERMES_HOME and temporary environment."""
h_home = tmp_path / "hermes_home"
h_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(h_home))
monkeypatch.setenv("DISPLAY", ":10.0")
# Initial empty router config
cfg = RouterConfig()
save_router_config(cfg)
return h_home
# ── TEST 1: Terminal discovery on Linux ──
@pytest.mark.unit
def test_terminal_discovery_linux_found(tmp_path, monkeypatch):
"""P0-1: find_terminal_emulator finds available terminal on Linux and returns correct command."""
monkeypatch.setattr(os, "name", "posix")
monkeypatch.setenv("DISPLAY", ":10.0")
def mock_which(cmd):
if cmd == "gnome-terminal":
return "/usr/bin/gnome-terminal"
return None
monkeypatch.setattr(shutil, "which", mock_which)
cmd, err, checked = find_terminal_emulator("ag-1", "/bin/agy", tmp_path)
assert err is None
assert cmd is not None
assert cmd[0] == "/usr/bin/gnome-terminal"
assert "--" in cmd
assert "/bin/agy" in cmd
assert any("gnome-terminal (найден: /usr/bin/gnome-terminal)" in item for item in checked)
@pytest.mark.unit
def test_terminal_discovery_linux_missing_honest_error(tmp_path, monkeypatch):
"""P0-1: When no terminal emulator exists on Linux, report honest error listing checked candidates."""
monkeypatch.setattr(os, "name", "posix")
monkeypatch.setenv("DISPLAY", ":10.0")
monkeypatch.setattr(shutil, "which", lambda cmd: None)
cmd, err, checked = find_terminal_emulator("ag-1", "/bin/agy", tmp_path)
assert cmd is None
assert err is not None
assert "Терминал не найден на сервере" in err
assert "x-terminal-emulator" in err
assert "gnome-terminal" in err
assert "konsole" in err
assert "xterm" in err
assert len(checked) >= 8
@pytest.mark.unit
def test_terminal_discovery_no_display_honest_error(tmp_path, monkeypatch):
"""P0-1: When DISPLAY/WAYLAND_DISPLAY are absent, return clear message advising browser fallback."""
monkeypatch.setattr(os, "name", "posix")
monkeypatch.delenv("DISPLAY", raising=False)
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
monkeypatch.delenv("MIR_SOCKET", raising=False)
cmd, err, checked = find_terminal_emulator("ag-1", "/bin/agy", tmp_path)
assert cmd is None
assert err is not None
assert "Графический дисплей не обнаружен" in err
assert "DISPLAY/WAYLAND_DISPLAY не заданы" in err
@pytest.mark.unit
def test_terminal_discovery_windows(tmp_path, monkeypatch):
"""P0-1: On Windows, find_terminal_emulator selects wt.exe or cmd.exe."""
monkeypatch.setattr(os, "name", "nt")
monkeypatch.setattr(shutil, "which", lambda c: "C:\\Windows\\System32\\wt.exe" if "wt" in c else None)
cmd, err, checked = find_terminal_emulator("ag-1", "C:\\bin\\agy.exe", tmp_path)
assert err is None
assert cmd is not None
assert "wt.exe" in cmd[0]
monkeypatch.setattr(shutil, "which", lambda c: None)
cmd, err, checked = find_terminal_emulator("ag-1", "C:\\bin\\agy.exe", tmp_path)
assert err is None
assert cmd is not None
assert "cmd.exe" in cmd[0]
# ── TEST 2: Start native agy login & slot protection ──
@pytest.mark.unit
def test_start_native_agy_login_creates_isolated_home(tmp_path, monkeypatch):
"""P0-1/P0-2: start_native_agy_login sets up profile directory with 0700 permissions and launches terminal."""
slot = "ag-1"
pdir = get_profile_dir(slot, "antigravity")
mock_popen = MagicMock()
monkeypatch.setattr("subprocess.Popen", mock_popen)
monkeypatch.setattr("antigravity_provider.agy_subprocess.get_agy_exe", lambda: "/usr/local/bin/agy")
monkeypatch.setattr(
"antigravity_provider.agy_subprocess.find_terminal_emulator",
lambda sid, exe, p: (["/usr/bin/xterm", "-e", exe], None, ["xterm"]),
)
ok, msg, data = start_native_agy_login(profile_id=slot)
assert ok is True
assert "Терминал успешно запущен" in msg
assert data["profile_id"] == "ag-1"
assert "session_id" in data
assert pdir.is_dir()
# Verify subprocess called with isolated HOME
mock_popen.assert_called_once()
call_kwargs = mock_popen.call_args[1]
assert call_kwargs["env"]["HOME"] == str(pdir)
assert call_kwargs["env"]["USERPROFILE"] == str(pdir)
assert call_kwargs["cwd"] == str(pdir)
@pytest.mark.unit
def test_start_native_agy_login_occupied_slot_requires_confirmation(tmp_path, monkeypatch):
"""P0-2: Attempting to log into an already authenticated slot requires confirmation unless force=True."""
slot = "ag-2"
pdir = get_profile_dir(slot, "antigravity")
pdir.mkdir(parents=True, exist_ok=True)
auth_file = pdir / "auth.json"
auth_file.write_text(json.dumps({
"auth_method": "oauth",
"email": "existing.developer@gmail.com",
"token": {"access_token": "ya29.active_token", "refresh_token": "1//active_ref"},
}), encoding="utf-8")
# First attempt without force -> confirmation required
ok, msg, data = start_native_agy_login(profile_id=slot, force=False)
assert ok is False
assert data.get("confirmation_required") is True
assert "уже занят аккаунтом" in msg
# Second attempt with force=True -> proceeds
mock_popen = MagicMock()
monkeypatch.setattr("subprocess.Popen", mock_popen)
monkeypatch.setattr("antigravity_provider.agy_subprocess.get_agy_exe", lambda: "/usr/local/bin/agy")
monkeypatch.setattr(
"antigravity_provider.agy_subprocess.find_terminal_emulator",
lambda sid, exe, p: (["/usr/bin/xterm", "-e", exe], None, ["xterm"]),
)
ok, msg, data = start_native_agy_login(profile_id=slot, force=True)
assert ok is True
assert "session_id" in data
# ── TEST 3: Polling detection of credentials written by agy ──
@pytest.mark.unit
def test_poll_native_agy_login_detects_antigravity_oauth_token(tmp_path, monkeypatch):
"""P0-1/P0-3: Poller detects .gemini/antigravity-cli/antigravity-oauth-token written by agy."""
slot = "ag-3"
pdir = get_profile_dir(slot, "antigravity")
monkeypatch.setattr("subprocess.Popen", MagicMock())
monkeypatch.setattr("antigravity_provider.agy_subprocess.get_agy_exe", lambda: "/usr/local/bin/agy")
monkeypatch.setattr(
"antigravity_provider.agy_subprocess.find_terminal_emulator",
lambda sid, exe, p: (["/usr/bin/xterm", "-e", exe], None, ["xterm"]),
)
ok, msg, data = start_native_agy_login(profile_id=slot)
assert ok is True
session_id = data["session_id"]
# While file is not written yet -> pending
ok, msg, p_data = poll_native_agy_login(session_id)
assert ok is True
assert p_data["status"] == "pending"
# Simulate agy CLI writing its token file
cli_dir = pdir / ".gemini" / "antigravity-cli"
cli_dir.mkdir(parents=True, exist_ok=True)
token_file = cli_dir / "antigravity-oauth-token"
token_file.write_text(json.dumps({
"auth_method": "consumer",
"token": {
"access_token": "ya29.native_agy_token",
"refresh_token": "1//native_agy_refresh",
"token_type": "Bearer",
"expiry": "2026-09-01T16:00:00Z",
},
}), encoding="utf-8")
# Simulate active account identification
acc_file = pdir / ".gemini" / "google_accounts.json"
acc_file.write_text(json.dumps({"active": "agy.native@gmail.com"}), encoding="utf-8")
# Poller should detect completion
ok, msg, p_data = poll_native_agy_login(session_id)
assert ok is True
assert p_data["status"] == "completed"
assert p_data["email"] == "agy.native@gmail.com"
assert p_data["profile_id"] == "ag-3"
@pytest.mark.unit
def test_poll_native_agy_login_truthful_email_or_na(tmp_path, monkeypatch):
"""P0-3: When email identity cannot be found, display 'Н', never an invented name."""
slot = "ag-4"
pdir = get_profile_dir(slot, "antigravity")
monkeypatch.setattr("subprocess.Popen", MagicMock())
monkeypatch.setattr("antigravity_provider.agy_subprocess.get_agy_exe", lambda: "/usr/local/bin/agy")
monkeypatch.setattr(
"antigravity_provider.agy_subprocess.find_terminal_emulator",
lambda sid, exe, p: (["/usr/bin/xterm", "-e", exe], None, ["xterm"]),
)
ok, msg, data = start_native_agy_login(profile_id=slot)
session_id = data["session_id"]
cli_dir = pdir / ".gemini" / "antigravity-cli"
cli_dir.mkdir(parents=True, exist_ok=True)
token_file = cli_dir / "antigravity-oauth-token"
token_file.write_text(json.dumps({
"auth_method": "consumer",
"token": {
"access_token": "ya29.anon_token",
"refresh_token": "1//anon_refresh",
},
}), encoding="utf-8")
ok, msg, p_data = poll_native_agy_login(session_id)
assert ok is True
assert p_data["status"] == "completed"
assert "Н" in p_data["email"]
@pytest.mark.unit
def test_poll_native_agy_login_duplicate_identity_deduplication(tmp_path, monkeypatch):
"""P0-3: When logging into a new slot with an account already registered in another slot, redirect to existing slot."""
# Slot 1 already has account
s1_dir = get_profile_dir("ag-1", "antigravity")
s1_dir.mkdir(parents=True, exist_ok=True)
(s1_dir / "auth.json").write_text(json.dumps({
"email": "corp.developer@company.com",
"auth_method": "oauth",
"token": {"access_token": "ya29.old", "refresh_token": "1//old"},
}), encoding="utf-8")
cfg = RouterConfig()
cfg.profiles["ag-1"] = RouterProfileConfig(profile_id="ag-1", provider="antigravity")
save_router_config(cfg)
# User attempts login into ag-5
s5_dir = get_profile_dir("ag-5", "antigravity")
monkeypatch.setattr("subprocess.Popen", MagicMock())
monkeypatch.setattr("antigravity_provider.agy_subprocess.get_agy_exe", lambda: "/usr/local/bin/agy")
monkeypatch.setattr(
"antigravity_provider.agy_subprocess.find_terminal_emulator",
lambda sid, exe, p: (["/usr/bin/xterm", "-e", exe], None, ["xterm"]),
)
ok, msg, data = start_native_agy_login(profile_id="ag-5")
session_id = data["session_id"]
# agy writes credentials for corp.developer@company.com into ag-5
cli_dir = s5_dir / ".gemini" / "antigravity-cli"
cli_dir.mkdir(parents=True, exist_ok=True)
(cli_dir / "antigravity-oauth-token").write_text(json.dumps({
"auth_method": "consumer",
"token": {"access_token": "ya29.new_corp", "refresh_token": "1//new_corp"},
}), encoding="utf-8")
(s5_dir / ".gemini" / "google_accounts.json").write_text(
json.dumps({"active": "corp.developer@company.com"}), encoding="utf-8"
)
ok, msg, p_data = poll_native_agy_login(session_id)
assert ok is True
assert p_data["status"] == "completed"
# De-duplicated back to ag-1
assert p_data["profile_id"] == "ag-1"
assert p_data["email"] == "corp.developer@company.com"
# ── TEST 4: Action executor integration & browser fallback ──
@pytest.mark.unit
def test_action_handler_native_auth_routes(monkeypatch):
"""P0-1/P0-5: ActionExecutor handles start_native_auth and poll_native_auth."""
monkeypatch.setattr("subprocess.Popen", MagicMock())
monkeypatch.setattr("antigravity_provider.agy_subprocess.get_agy_exe", lambda: "/usr/local/bin/agy")
monkeypatch.setattr(
"antigravity_provider.agy_subprocess.find_terminal_emulator",
lambda sid, exe, p: (["/usr/bin/xterm", "-e", exe], None, ["xterm"]),
)
res = ActionExecutor.execute("start_native_auth", {"provider": "antigravity", "profile_id": "ag-6"})
assert res["ok"] is True
session_id = res["data"]["session_id"]
poll_res = ActionExecutor.execute("poll_native_auth", {"session_id": session_id})
assert poll_res["ok"] is True
assert poll_res["data"]["status"] == "pending"
cancel_res = ActionExecutor.execute("cancel_native_auth", {"session_id": session_id})
assert cancel_res["ok"] is True
@pytest.mark.unit
def test_browser_redirect_auth_fallback_preserved():
"""P0-4: Existing browser OAuth redirect path is fully functional as fallback."""
res = ActionExecutor.execute("start_redirect_auth", {"provider": "antigravity", "profile_id": "ag-7"})
assert res["ok"] is True
assert "url" in res["data"]
assert "session_id" in res["data"]
assert res["data"]["paste_kind"] == "url"
ActionExecutor.execute("cancel_redirect_auth", {"session_id": res["data"]["session_id"]})
@pytest.mark.unit
def test_permissions_0700_and_0600(tmp_path):
"""P0-6: Profile directories are created with 0700 and credential files with 0600."""
slot = "ag-8"
pdir = get_profile_dir(slot, "antigravity")
pdir.mkdir(parents=True, exist_ok=True)
os.chmod(pdir, 0o700)
cli_dir = pdir / ".gemini" / "antigravity-cli"
cli_dir.mkdir(parents=True, exist_ok=True)
token_file = cli_dir / "antigravity-oauth-token"
token_file.write_text(json.dumps({
"auth_method": "consumer",
"token": {"access_token": "ya29.perm_test", "refresh_token": "1//perm"},
}), encoding="utf-8")
check_profile_native_auth_status(slot)
# Check directory permissions (on POSIX systems)
if os.name != "nt":
assert oct(pdir.stat().st_mode & 0o777) == "0o700"
assert oct(token_file.stat().st_mode & 0o777) == "0o600"