- Реализован запуск 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 тестов проходят
384 lines
15 KiB
Python
384 lines
15 KiB
Python
"""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"
|