feat(agy): A58 read-only eligibility state detection and owner patcher controls

This commit is contained in:
ochenstarik-ui 2026-09-02 01:26:46 +07:00
parent 7b36538ded
commit 4860c5685e
10 changed files with 1057 additions and 19 deletions

View file

@ -904,25 +904,26 @@ def find_terminal_emulator(
profile_id: str,
agy_exe: str,
profile_dir: Path,
title: str | None = None,
) -> tuple[list[str] | None, str | None, list[str]]:
"""Locate an available GUI terminal emulator on the host system to run native agy CLI login.
Returns:
(command_args, error_message, checked_candidates)
"""
title = f"Antigravity Login ({profile_id})"
term_title = title or f"Antigravity Login ({profile_id})"
if _is_windows():
checked = ["Windows Terminal (wt.exe)", "cmd.exe", "powershell.exe"]
wt_path = shutil.which("wt.exe") or shutil.which("wt")
if wt_path:
cmd = [wt_path, "-w", "0", "nt", "-d", str(profile_dir), "--title", title, agy_exe]
cmd = [wt_path, "-w", "0", "nt", "-d", str(profile_dir), "--title", term_title, agy_exe]
return cmd, None, checked
cmd_path = shutil.which("cmd.exe") or shutil.which("cmd") or "cmd.exe"
# /k вместо /c: иначе окно исчезает вместе с agy и причина отказа
# остаётся непрочитанной.
cmd = [cmd_path, "/c", "start", title, "cmd", "/k", agy_exe]
cmd = [cmd_path, "/c", "start", term_title, "cmd", "/k", agy_exe]
return cmd, None, checked
# Linux / Unix / macOS
@ -946,16 +947,16 @@ def find_terminal_emulator(
# уже работающему экземпляру и наш процесс умирает, не открыв окна, —
# именно это владелец и увидел.
candidates: list[tuple[str, Any]] = [
("xfce4-terminal", lambda p: [p, "--disable-server", "--title", title, "-e", launch]),
("konsole", lambda p: [p, "-p", f"tabtitle={title}", "-e", launch]),
("tilix", lambda p: [p, "-t", title, "-e", launch]),
("alacritty", lambda p: [p, "-t", title, "-e", launch]),
("kitty", lambda p: [p, "--title", title, launch]),
("terminator", lambda p: [p, "-T", title, "-e", launch]),
("urxvt", lambda p: [p, "-title", title, "-e", launch]),
("foot", lambda p: [p, "--title", title, launch]),
("xterm", lambda p: [p, "-title", title, "-e", launch]),
("gnome-terminal", lambda p: [p, "--title", title, "--", launch]),
("xfce4-terminal", lambda p: [p, "--disable-server", "--title", term_title, "-e", launch]),
("konsole", lambda p: [p, "-p", f"tabtitle={term_title}", "-e", launch]),
("tilix", lambda p: [p, "-t", term_title, "-e", launch]),
("alacritty", lambda p: [p, "-t", term_title, "-e", launch]),
("kitty", lambda p: [p, "--title", term_title, launch]),
("terminator", lambda p: [p, "-T", term_title, "-e", launch]),
("urxvt", lambda p: [p, "-title", term_title, "-e", launch]),
("foot", lambda p: [p, "--title", term_title, launch]),
("xterm", lambda p: [p, "-title", term_title, "-e", launch]),
("gnome-terminal", lambda p: [p, "--title", term_title, "--", launch]),
("x-terminal-emulator", lambda p: [p, "-e", launch]),
]
@ -977,6 +978,93 @@ def find_terminal_emulator(
return None, err_msg, checked
def write_terminal_script_helper(
work_dir: Path,
command_args: list[str],
title: str,
env_vars: dict[str, str] | None = None,
) -> Path:
"""Создать исполняемый сценарий запуска в терминале с удержанием окна."""
work_dir.mkdir(parents=True, exist_ok=True)
helper_path = work_dir / f".hermes-task-{secrets.token_hex(4)}.sh"
cmd_str = " ".join(shlex.quote(a) for a in command_args)
lines = [
"#!/bin/sh",
f"# Hermes Hub terminal helper: {title}",
]
if env_vars:
for k, v in sorted(env_vars.items()):
lines.append(f"{k}={shlex.quote(v)}")
lines.append("export " + " ".join(sorted(env_vars.keys())))
lines += [
f"cd {shlex.quote(str(work_dir))} || exit 1",
f'echo "=== {title} ==="',
'echo',
cmd_str,
"status=$?",
'echo',
'echo "Команда завершилась с кодом $status. Окно можно закрыть."',
'printf "Нажмите Enter... "',
"read _ignored",
]
helper_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
try:
os.chmod(helper_path, 0o700)
except OSError:
pass
return helper_path
def launch_terminal_task(
title: str,
command_args: list[str],
work_dir: Path | None = None,
env_overrides: dict[str, str] | None = None,
) -> tuple[bool, str, dict[str, Any]]:
"""Запустить команду в терминале через find_terminal_emulator."""
from antigravity_provider.paths import get_hermes_home
wdir = work_dir or get_hermes_home()
wdir.mkdir(parents=True, exist_ok=True)
script_path = str(write_terminal_script_helper(wdir, command_args, title, env_vars=env_overrides))
term_cmd, err_msg, checked = find_terminal_emulator("task", script_path, wdir, title=title)
if err_msg or not term_cmd:
return False, err_msg or "Терминал не найден", {"checked_terminals": checked}
session_env, _checked = detect_graphical_session()
env = build_safe_subprocess_env(overrides=dict(session_env))
if env_overrides:
env.update(env_overrides)
try:
proc = subprocess.Popen(
term_cmd,
env=env,
cwd=str(wdir),
stdin=None,
stdout=None,
stderr=None,
)
time.sleep(1.0)
exit_code = proc.poll()
if isinstance(exit_code, int) and exit_code != 0:
return False, f"Терминал {term_cmd[0]} завершился сразу с кодом {exit_code}", {
"checked_terminals": checked,
"terminal_cmd": term_cmd[0],
"exit_code": exit_code,
}
except Exception as exc:
return False, f"Не удалось запустить терминал ({term_cmd[0]}): {exc}", {
"checked_terminals": checked,
}
return True, f"Команда «{title}» успешно запущена в терминале", {
"terminal_cmd": term_cmd[0],
"work_dir": str(wdir),
}
class NativeAgySession:
"""Tracks a native agy CLI terminal login session."""

View file

@ -1495,5 +1495,77 @@ class ActionExecutor:
}
}
# ── Проверка доступности agy и сценарии владельца (A58) ──
elif action == 'refresh_agy_eligibility':
from antigravity_provider.router.agy_eligibility_service import AgyEligibilityService
service = AgyEligibilityService.get()
service.invalidate_cache()
state = service.check_eligibility_state(force=True)
return {
'ok': True,
'message': f"Состояние проверки agy: {state.get('status_label_ru')}",
'data': state,
}
elif action == 'run_agy_patch_script':
from pathlib import Path
from antigravity_provider.router.settings_service import get_hub_settings
from antigravity_provider.router.agy_eligibility_service import AgyEligibilityService
from antigravity_provider.agy_subprocess import launch_terminal_task
settings = get_hub_settings()
raw_path = str(settings.get('agy_patch_script_path') or '').strip()
if not raw_path:
return {'ok': False, 'message': 'Н/Д: путь к сценарию патча не указан в настройках'}
p = Path(raw_path).expanduser().resolve()
if not p.is_file() or not (os.access(p, os.X_OK) or p.suffix in ('.sh', '.py', '.bat', '.cmd', '.exe')):
return {'ok': False, 'message': f'Файл сценария не найден или недоступен: {raw_path}'}
ok, msg, res_data = launch_terminal_task(
title='Antigravity Eligibility Patch',
command_args=[str(p)],
work_dir=p.parent,
)
if not ok:
return {'ok': False, 'message': f'Не удалось запустить сценарий в терминале: {msg}', 'data': res_data}
service = AgyEligibilityService.get()
service.invalidate_cache()
new_state = service.check_eligibility_state(force=True)
return {
'ok': True,
'message': 'Сценарий патча запущен в окне терминала',
'data': {'eligibility': new_state, 'terminal': res_data},
}
elif action == 'run_agy_update':
from antigravity_provider.agy_subprocess import get_agy_exe, launch_terminal_task
from antigravity_provider.router.agy_eligibility_service import AgyEligibilityService
from antigravity_provider.paths import get_hermes_home
try:
agy_exe = get_agy_exe()
except Exception as exc:
return {'ok': False, 'message': f'Утилита agy не найдена: {exc}'}
ok, msg, res_data = launch_terminal_task(
title='Обновление Antigravity CLI (agy update)',
command_args=[agy_exe, 'update'],
work_dir=get_hermes_home(),
)
if not ok:
return {'ok': False, 'message': f'Не удалось запустить обновление в терминале: {msg}', 'data': res_data}
service = AgyEligibilityService.get()
service.invalidate_cache()
new_state = service.check_eligibility_state(force=True)
return {
'ok': True,
'message': 'Обновление agy запущено в окне терминала',
'data': {'eligibility': new_state, 'terminal': res_data},
}
else:
return {'ok': False, 'message': f'Неизвестное действие: {action}', 'unknown': True}

View file

@ -0,0 +1,329 @@
"""Hermes Hub — Antigravity CLI (agy) Eligibility State Detection Service.
Provides 100% read-only analysis of the agy binary machine code to determine whether
the region eligibility check is active, removed (patched by owner), or undetermined.
Strictly NEVER modifies or writes to executable files, and NEVER downloads external code.
"""
from __future__ import annotations
import hashlib
import logging
import os
import re
import subprocess
import threading
import time
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from antigravity_provider.agy_subprocess import (
build_safe_subprocess_env,
get_agy_exe,
hidden_process_kwargs,
)
from antigravity_provider.router.event_bus import (
EVENT_AGY_ELIGIBILITY_CHANGED,
EventBus,
)
from antigravity_provider.router.settings_service import get_hub_settings
from antigravity_provider.router.unified_health import EventLogService
logger = logging.getLogger("hermes.router.agy_eligibility")
STATUS_CHECK_REMOVED = "check_removed"
STATUS_CHECK_ACTIVE = "check_active"
STATUS_UNKNOWN = "unknown"
# ── Machine Code Signatures ──────────────────────────────────────────
# x86-64 Original:
# test rax,rax ; je eligible ; cmp byte[rax+8],0 ; jne eligible ; call failure
# cmp byte[rax+8], 0 is \x80\x78\x08\x00
X86_ORIG_SHORT = re.compile(rb"\x48\x85\xc0\x74.\x80\x78\x08\x00\x75.", re.DOTALL)
X86_ORIG_NEAR = re.compile(rb"\x48\x85\xc0\x0f\x84....\x80\x78\x08\x00\x0f\x85....", re.DOTALL)
X86_ORIG_STANDALONE = re.compile(rb"\x80\x78\x08\x00", re.DOTALL)
# x86-64 Patched:
# test rax,rax ; je eligible ; test rax,rax ; nop ; jne eligible
# test rax,rax ; nop is \x48\x85\xc0\x90
X86_PATCH_SHORT = re.compile(rb"\x48\x85\xc0\x74.\x48\x85\xc0\x90\x75.", re.DOTALL)
X86_PATCH_NEAR = re.compile(rb"\x48\x85\xc0\x0f\x84....\x48\x85\xc0\x90\x0f\x85....", re.DOTALL)
# arm64 Original & Patched signatures
# In Go arm64, struct field [X0, #8] access followed by conditional branch:
# LDRB Wn, [X0, #8] -> \x0n\x20\x40\x39
ARM64_ORIG_PATTERN = re.compile(rb"[\x00-\x1f]\x20\x40\x39", re.DOTALL)
# Patched replaces LDRB / branch with NOP (\x1f\x20\x03\xd5) or MOV X0, X0 (\xe0\x03\x00\xaa)
ARM64_PATCH_PATTERN = re.compile(rb"\x1f\x20\x03\xd5", re.DOTALL)
class AgyEligibilityService:
"""Thread-safe, read-only analyzer of agy CLI binary eligibility check status."""
_instance: Optional[AgyEligibilityService] = None
_instance_lock = threading.Lock()
def __init__(self) -> None:
self._lock = threading.RLock()
self._cached_state: Optional[Dict[str, Any]] = None
self._last_binary_path: Optional[str] = None
self._last_mtime: float = -1.0
self._last_sha256: Optional[str] = None
self._last_status: Optional[str] = None
self._last_status_label: Optional[str] = None
self._last_version: Optional[str] = None
@classmethod
def get(cls) -> AgyEligibilityService:
if cls._instance is None:
with cls._instance_lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def invalidate_cache(self) -> None:
"""Clear cached eligibility state to force re-reading binary on next check."""
with self._lock:
self._cached_state = None
self._last_mtime = -1.0
def _extract_version(self, exe_path: str) -> str:
"""Truthfully extract version from agy executable."""
try:
res = subprocess.run(
[exe_path, "--version"],
capture_output=True,
text=True,
timeout=5,
encoding="utf-8",
errors="replace",
env=build_safe_subprocess_env(),
**hidden_process_kwargs(),
)
raw = res.stdout.strip() or res.stderr.strip()
if raw:
# E.g. "1.1.23" or "Antigravity CLI 1.1.23"
m = re.search(r"(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?|\d+\.\d+)", raw)
if m:
return m.group(1)
return raw.splitlines()[0][:50]
except Exception as exc:
logger.debug("Could not run agy --version: %s", exc)
return "Н/Д (не удалось определить версию)"
def _analyze_binary_bytes(self, data: bytes) -> Tuple[str, str, str]:
"""Analyze binary machine code bytes.
Returns:
(status, status_label_ru, detail_ru)
"""
# 1. Check for patched x86-64 signature
if X86_PATCH_SHORT.search(data) or X86_PATCH_NEAR.search(data):
return (
STATUS_CHECK_REMOVED,
"Проверка снята",
"Патч начальной проверки доступности активен. Ветвление направлено в разрешённый режим.",
)
# 2. Check for original x86-64 signature
if X86_ORIG_SHORT.search(data) or X86_ORIG_NEAR.search(data):
return (
STATUS_CHECK_ACTIVE,
"Проверка на месте",
"Проверка доступности Antigravity активна. Аккаунт может отклоняться Google по региону. Примените патч или настройте прокси.",
)
# 3. Check for arm64 patched / orig if context matches
if ARM64_PATCH_PATTERN.search(data) and b"EPD_ELIGIBILITY" in data:
if not ARM64_ORIG_PATTERN.search(data):
return (
STATUS_CHECK_REMOVED,
"Проверка снята",
"Патч начальной проверки доступности (ARM64) активен.",
)
if ARM64_ORIG_PATTERN.search(data) and b"EPD_ELIGIBILITY" in data:
return (
STATUS_CHECK_ACTIVE,
"Проверка на месте",
"Проверка доступности Antigravity (ARM64) активна. Аккаунт может отклоняться Google по региону.",
)
# 4. Unknown / Unsupported
return (
STATUS_UNKNOWN,
"Н/Д: сигнатура проверки не найдена",
"Сигнатура проверки доступности не найдена в бинарнике (возможно, неподдерживаемая версия agy).",
)
def check_eligibility_state(
self,
force: bool = False,
custom_binary_path: Optional[str | Path] = None,
) -> Dict[str, Any]:
"""Perform 100% read-only evaluation of agy binary eligibility state.
Guarantees:
- NEVER writes to or alters the executable file.
- Calculates SHA-256 before and after evaluation.
- Emits EventBus event and EventLogService audit record upon state transitions.
"""
with self._lock:
# Resolve binary path
if custom_binary_path:
exe_path_str = str(custom_binary_path)
else:
try:
exe_path_str = get_agy_exe()
except Exception as exc:
exe_path_str = ""
error_msg = str(exc)
settings = get_hub_settings()
patch_script_path = settings.get("agy_patch_script_path", "").strip()
if not exe_path_str:
state = {
"status": STATUS_UNKNOWN,
"status_label_ru": "Н/Д: исполняемый файл agy не найден",
"detail_ru": f"Утилита agy не обнаружена в системе: {error_msg if 'error_msg' in locals() else 'путь не найден'}",
"version": "Н",
"binary_path": "",
"binary_sha256": "",
"binary_size_bytes": 0,
"checked_at": time.time(),
"patch_script_path": patch_script_path,
}
self._cached_state = state
return dict(state)
p = Path(exe_path_str)
if not p.is_file():
state = {
"status": STATUS_UNKNOWN,
"status_label_ru": "Н/Д: файл не найден",
"detail_ru": f"Файл {p} не существует или не является файлом",
"version": "Н",
"binary_path": str(p),
"binary_sha256": "",
"binary_size_bytes": 0,
"checked_at": time.time(),
"patch_script_path": patch_script_path,
}
self._cached_state = state
return dict(state)
try:
stat = p.stat()
mtime = stat.st_mtime
size = stat.st_size
except OSError as exc:
state = {
"status": STATUS_UNKNOWN,
"status_label_ru": f"Н/Д: нет доступа к файлу ({exc.strerror or exc})",
"detail_ru": f"Не удалось прочитать атрибуты файла {p}: {exc}",
"version": "Н",
"binary_path": str(p),
"binary_sha256": "",
"binary_size_bytes": 0,
"checked_at": time.time(),
"patch_script_path": patch_script_path,
}
self._cached_state = state
return dict(state)
# Return cached state if nothing changed and not forced
if (
not force
and self._cached_state is not None
and self._last_binary_path == str(p)
and self._last_mtime == mtime
):
# Update patch_script_path if settings changed
self._cached_state["patch_script_path"] = patch_script_path
return dict(self._cached_state)
# Read-only binary read and hash computation
try:
with open(p, "rb") as f:
data = f.read()
except OSError as exc:
state = {
"status": STATUS_UNKNOWN,
"status_label_ru": f"Н/Д: ошибка чтения файла ({exc.strerror or exc})",
"detail_ru": f"Не удалось прочитать бинарный файл {p}: {exc}",
"version": "Н",
"binary_path": str(p),
"binary_sha256": "",
"binary_size_bytes": size,
"checked_at": time.time(),
"patch_script_path": patch_script_path,
}
self._cached_state = state
return dict(state)
sha256_hash = hashlib.sha256(data).hexdigest()
status, status_label_ru, detail_ru = self._analyze_binary_bytes(data)
# Extract version
version = self._extract_version(str(p))
state = {
"status": status,
"status_label_ru": status_label_ru,
"detail_ru": detail_ru,
"version": version,
"binary_path": str(p),
"binary_sha256": sha256_hash,
"binary_size_bytes": len(data),
"checked_at": time.time(),
"patch_script_path": patch_script_path,
}
# Detect state transition and emit notifications (P0-3)
previous_status = self._last_status
previous_label = self._last_status_label or previous_status or "Н"
previous_sha = self._last_sha256
if previous_status is not None and previous_status != status:
logger.info(
"AGY eligibility state changed: %s -> %s (sha256: %s)",
previous_status,
status,
sha256_hash[:12],
)
EventBus.get().publish(EVENT_AGY_ELIGIBILITY_CHANGED, dict(state))
level = "warning" if status == STATUS_CHECK_ACTIVE else "info"
EventLogService.get().log(
category="security",
message=(
f"Состояние проверки доступности agy изменилось: "
f"{previous_label}{status_label_ru}"
),
details=f"Исполняемый файл: {p} (SHA-256: {sha256_hash[:16]}..., Версия: {version})",
level=level,
actor="system",
action="agy_eligibility_change",
target_profile="antigravity",
outcome="success",
)
elif previous_sha is not None and previous_sha != sha256_hash:
logger.info("AGY binary hash changed: %s -> %s", previous_sha[:12], sha256_hash[:12])
EventLogService.get().log(
category="system",
message=f"Обнаружено изменение исполняемого файла agy (SHA-256: {sha256_hash[:16]}..., статус: {status_label_ru})",
level="info",
actor="system",
action="agy_binary_updated",
target_profile="antigravity",
)
self._last_binary_path = str(p)
self._last_mtime = mtime
self._last_sha256 = sha256_hash
self._last_status = status
self._last_status_label = status_label_ru
self._last_version = version
self._cached_state = state
return dict(state)

View file

@ -24,6 +24,8 @@ EVENT_REFRESH_STARTED = "REFRESH_STARTED"
EVENT_REFRESH_COMPLETED = "REFRESH_COMPLETED"
EVENT_REFRESH_FAILED = "REFRESH_FAILED"
EVENT_AGY_ELIGIBILITY_CHANGED = "AGY_ELIGIBILITY_CHANGED"
class EventBus:
"""Central thread-safe EventBus for decoupling backend state changes from UI rendering."""

View file

@ -43,6 +43,9 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
# in your location». Задаётся адресом вида socks5://127.0.0.1:1080 или
# http://127.0.0.1:8080; пустое значение означает «без прокси».
"provider_proxy_url": "",
# Путь к пользовательскому сценарию патча проверки доступности agy (A58).
# Пустое значение означает «сценарий не указан».
"agy_patch_script_path": "",
}
@ -186,6 +189,7 @@ def get_hub_settings() -> Dict[str, Any]:
merged["compression_keep_recent_messages"] = 3
merged["compression_enabled"] = bool(merged.get("compression_enabled", True))
merged["agy_patch_script_path"] = str(merged.get("agy_patch_script_path") or "").strip()
_SETTINGS_CACHE = dict(merged)
_SETTINGS_CACHE_MTIME = current_mtime

View file

@ -258,6 +258,21 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)):
"config_dir": str(paths.get_config_dir()),
"log_file": str(paths.get_log_file()),
}
try:
from antigravity_provider.router.agy_eligibility_service import AgyEligibilityService
snap_dict["agy_eligibility"] = AgyEligibilityService.get().check_eligibility_state(force=False)
except Exception as exc:
snap_dict["agy_eligibility"] = {
"status": "unknown",
"status_label_ru": f"Н/Д: {exc}",
"detail_ru": str(exc),
"version": "Н",
"binary_path": "",
"binary_sha256": "",
"binary_size_bytes": 0,
"checked_at": time.time(),
"patch_script_path": "",
}
return JSONResponse(content=jsonable_encoder(snap_dict))
@app.post("/api/action")
@ -523,6 +538,21 @@ def get_settings(authorized: bool = Depends(get_auth_token)):
),
},
}
try:
from antigravity_provider.router.agy_eligibility_service import AgyEligibilityService
settings_out["agy_eligibility"] = AgyEligibilityService.get().check_eligibility_state(force=False)
except Exception as exc:
settings_out["agy_eligibility"] = {
"status": "unknown",
"status_label_ru": f"Н/Д: {exc}",
"detail_ru": str(exc),
"version": "Н",
"binary_path": "",
"binary_sha256": "",
"binary_size_bytes": 0,
"checked_at": time.time(),
"patch_script_path": "",
}
for k, v in raw.items():
if k not in settings_out and not any(secret in k.lower() for secret in ['token', 'secret', 'key', 'password', 'jwt']):
settings_out[k] = v

View file

@ -210,6 +210,42 @@ function initEventListeners() {
btnResetConfig.addEventListener('click', () => openResetConfigModal());
}
// Antigravity eligibility actions (A58)
const btnRefreshAgyElig = document.getElementById('btn-refresh-agy-eligibility');
if (btnRefreshAgyElig) {
btnRefreshAgyElig.addEventListener('click', async () => {
showToast('Проверка состояния agy...', 'info');
const res = await executeAction('refresh_agy_eligibility');
await fetchSettings();
await fetchSnapshot();
if (res && res.ok) showToast(res.message || 'Состояние обновлено', 'success');
});
}
const btnRunAgyUpdate = document.getElementById('btn-run-agy-update');
if (btnRunAgyUpdate) {
btnRunAgyUpdate.addEventListener('click', async () => {
if (!confirm('Обновление agy перезаписывает исполняемый файл и возвращает проверку доступности: сначала выполните обновление, затем повторно примените патч. Запустить agy update в терминале?')) return;
const res = await executeAction('run_agy_update');
await fetchSettings();
await fetchSnapshot();
});
}
const btnRunAgyPatch = document.getElementById('btn-run-agy-patch-script');
if (btnRunAgyPatch) {
btnRunAgyPatch.addEventListener('click', async () => {
const pathVal = (document.getElementById('setting-agy-patch-script-path')?.value || '').trim();
if (!pathVal && !currentSettings?.agy_patch_script_path) {
showToast('Н/Д: путь к сценарию патча не указан в настройках', 'warning');
return;
}
const res = await executeAction('run_agy_patch_script');
await fetchSettings();
await fetchSnapshot();
});
}
// Skills view event listeners
const skillsSearch = document.getElementById('skills-search');
const filterSkillsSource = document.getElementById('filter-skills-source');
@ -877,6 +913,37 @@ function renderAccountCard(profile) {
const unassignedBadge = !isAssigned ? '<span class="badge badge-unassigned">Не назначен</span>' : '';
let agyEligibilityHtml = '';
if (['antigravity', 'google-antigravity'].includes(String(profile.provider || '').toLowerCase())) {
const agyElig = (currentSnapshot && currentSnapshot.agy_eligibility) || {};
let badgeClass = '';
let badgeText = agyElig.status_label_ru || 'Н/Д';
if (agyElig.status === 'check_removed') {
badgeClass = 'healthy';
badgeText = '✓ Проверка снята';
} else if (agyElig.status === 'check_active') {
badgeClass = 'warning';
badgeText = '⚠️ Проверка на месте';
}
const badgeHtml = `<span class="badge badge-status ${badgeClass}" title="${escapeHtml(agyElig.detail_ru || '')}">${escapeHtml(badgeText)}</span>`;
let warningHtml = '';
if (agyElig.status === 'check_active') {
warningHtml = `
<div class="account-eligibility-warning" style="margin-top:6px; padding:6px 8px; background:rgba(230,162,60,0.15); border-left:3px solid var(--status-warning); border-radius:3px; font-size:11px; color:var(--status-warning); line-height:1.4;">
<strong>Проверка доступности Antigravity активна.</strong> Аккаунт может отклоняться Google по региону. Примените патч или настройте прокси.
</div>
`;
}
agyEligibilityHtml = `
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:6px; font-size:11px; color:var(--text-muted);">
<span>Доступность agy:</span>
${badgeHtml}
</div>
${warningHtml}
`;
}
return `
<div class="account-card ${isMain ? 'main-account' : ''}" data-profile-id="${escapeHtml(profile.profile_id)}">
<div class="account-card-header">
@ -898,6 +965,7 @@ function renderAccountCard(profile) {
</div>
<div class="account-models"><span>Предпочитаемые:</span>${(profile.preferred_models || []).map(modelBrandLabel).join('')}</div>
${agyEligibilityHtml}
${renderAccountCheck(profile)}
${quotaGridHtml}
</div>
@ -1590,14 +1658,22 @@ function renderHealthView() {
const warningsContainer = document.getElementById('health-warnings-list');
if (warningsContainer) {
const warnings = readiness.warnings || [];
if (warnings.length === 0) {
const agyElig = currentSnapshot.agy_eligibility || {};
const agyWarnHtml = (agyElig.status === 'check_active')
? `<div class="warning-item" style="padding:8px 12px; margin-bottom:6px; border-left:3px solid var(--status-warning); background:var(--surface-muted); font-size:12px;">
<strong> Проверка доступности Antigravity активна</strong>: аккаунты Google могут отклоняться по региону («not currently available in your location»). Примените патч в Настройках или используйте прокси.
</div>`
: '';
if (warnings.length === 0 && !agyWarnHtml) {
warningsContainer.innerHTML = '<div style="padding:14px; color:var(--status-healthy); font-size:12px;">✓ Критических предупреждений и деградаций не обнаружено</div>';
} else {
warningsContainer.innerHTML = warnings.map((w) => `
const regularWarnings = warnings.map((w) => `
<div class="warning-item" style="padding:8px 12px; margin-bottom:6px; border-left:3px solid var(--status-warning); background:var(--surface-muted); font-size:12px;">
<strong> ${escapeHtml(w.title || 'Предупреждение')}</strong>: ${escapeHtml(w.message || w)}
</div>
`).join('');
warningsContainer.innerHTML = agyWarnHtml + regularWarnings;
}
}
}
@ -1824,9 +1900,44 @@ function renderSettingsView() {
if (compKeepRecentSel && s.compression_keep_recent_messages !== undefined) {
compKeepRecentSel.value = String(s.compression_keep_recent_messages);
}
// checkCompressionStatus() отсюда убран: отрисовка настроек происходит на
// каждом обновлении снапшота, и опрос замыкал круг сам на себя. Состояние
// запрашивается при открытии экрана настроек и по кнопке.
// Antigravity CLI & Eligibility Settings (A58)
const agyPatchInput = document.getElementById('setting-agy-patch-script-path');
if (agyPatchInput) {
agyPatchInput.value = s.agy_patch_script_path || '';
}
const agyElig = s.agy_eligibility || (currentSnapshot && currentSnapshot.agy_eligibility) || {};
const agyBadge = document.getElementById('agy-eligibility-badge');
const agyDesc = document.getElementById('agy-version-path-desc');
const agyDetails = document.getElementById('agy-eligibility-details');
if (agyBadge) {
if (agyElig.status === 'check_removed') {
agyBadge.textContent = '✓ Проверка снята';
agyBadge.className = 'badge healthy';
} else if (agyElig.status === 'check_active') {
agyBadge.textContent = '⚠️ Проверка на месте';
agyBadge.className = 'badge warning';
} else {
agyBadge.textContent = agyElig.status_label_ru || 'Н/Д: не определено';
agyBadge.className = 'badge';
}
}
if (agyDesc) {
const ver = agyElig.version || 'Н/Д';
const bin = agyElig.binary_path || 'Н/Д';
agyDesc.innerHTML = `Версия: <strong>${escapeHtml(ver)}</strong> • Путь: <code class="mono-path">${escapeHtml(bin)}</code>`;
}
if (agyDetails) {
if (agyElig.binary_sha256) {
agyDetails.textContent = `SHA-256: ${agyElig.binary_sha256}\nРазмер: ${agyElig.binary_size_bytes || 0} байт\nОписание: ${agyElig.detail_ru || ''}`;
} else {
agyDetails.textContent = agyElig.detail_ru || '';
}
}
}
function populateCompressorProfiles(s) {
@ -1865,6 +1976,7 @@ async function saveHubServerSettings() {
const accountIntervalInput = document.getElementById('setting-account-check-interval');
const defaultRoleSel = document.getElementById('setting-default-role');
const themeSel = document.getElementById('setting-theme');
const agyPatchInput = document.getElementById('setting-agy-patch-script-path');
const proxyInputSave = document.getElementById('setting-provider-proxy-url');
@ -1872,6 +1984,7 @@ async function saveHubServerSettings() {
// Пустое поле — это выбор «без прокси», а не отсутствие значения:
// отправляем его тоже, иначе прокси нельзя было бы убрать.
if (proxyInputSave) newSettings.provider_proxy_url = proxyInputSave.value.trim();
if (agyPatchInput) newSettings.agy_patch_script_path = agyPatchInput.value.trim();
if (hostInput && hostInput.value.trim()) newSettings.web_api_host = hostInput.value.trim();
if (portInput && portInput.value) newSettings.web_api_port = Number(portInput.value);
if (tokenInput && tokenInput.value.trim()) newSettings.web_api_token = tokenInput.value.trim();
@ -2147,6 +2260,38 @@ function openAccountDetailsModal(profileId, isRedraw = false) {
`;
}
// Antigravity eligibility section
let agyModalBlockHtml = '';
if (['antigravity', 'google-antigravity'].includes(String(profile.provider || '').toLowerCase())) {
const agyElig = (currentSnapshot && currentSnapshot.agy_eligibility) || {};
let badgeClass = '';
let badgeText = agyElig.status_label_ru || 'Н/Д';
if (agyElig.status === 'check_removed') {
badgeClass = 'healthy';
badgeText = '✓ Проверка снята';
} else if (agyElig.status === 'check_active') {
badgeClass = 'warning';
badgeText = '⚠️ Проверка на месте';
}
agyModalBlockHtml = `
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:14px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">
<strong style="font-size:12px;">Состояние проверки доступности agy:</strong>
<span class="badge badge-status ${badgeClass}">${escapeHtml(badgeText)}</span>
</div>
<div style="font-size:11px; color:var(--text-secondary); margin-bottom:4px;">
${escapeHtml(agyElig.detail_ru || 'Состояние проверки доступности утилиты agy.')}
</div>
${agyElig.binary_path ? `<div style="font-size:11px; font-family:var(--font-mono); color:var(--text-muted); word-break:break-all;">Файл: ${escapeHtml(agyElig.binary_path)} (v${escapeHtml(agyElig.version || 'Н/Д')})</div>` : ''}
${agyElig.status === 'check_active' ? `
<div style="margin-top:6px; padding:6px 8px; background:rgba(230,162,60,0.15); border-left:3px solid var(--status-warning); border-radius:3px; font-size:11px; color:var(--status-warning);">
<strong>Внимание:</strong> Проверка доступности активна. Аккаунт может отклоняться Google по региону. Примените патч в Настройках или настройте прокси.
</div>
` : ''}
</div>
`;
}
modelBlockHtml = renderAccountCheck(profile) + modelBlockHtml;
elements.modalTitle.textContent = `Учетная запись: ${profile.display_name || profileId}`;
elements.modalBody.innerHTML = `
@ -2155,7 +2300,7 @@ function openAccountDetailsModal(profileId, isRedraw = false) {
<div style="font-size:14px; font-weight:700;">${escapeHtml(profile.account_identity || profile.email || profileId)}</div>
<div style="font-size:12px; color:var(--text-muted);">
Провайдер: <strong>${escapeHtml(profile.provider_display_name || profile.provider)}</strong>
Тариф: <strong>${escapeHtml(profile.plan || 'Неизвестen')}</strong>
Тариф: <strong>${escapeHtml(profile.plan || 'Неизвестен')}</strong>
Статус: <strong class="text-healthy">${escapeHtml(profile.health_label_ru || 'Работает')}</strong>
</div>
<div style="font-size:12px; color:var(--text-secondary); margin-top:4px;">
@ -2163,6 +2308,7 @@ function openAccountDetailsModal(profileId, isRedraw = false) {
</div>
</div>
${agyModalBlockHtml}
${modelBlockHtml}
${requestOptionsHtml}
${quotasHtml}

View file

@ -665,6 +665,43 @@
<div id="compression-details-box" style="font-size:12px; color:var(--text-muted); margin-top:8px; font-family:var(--font-mono); white-space:pre-wrap;"></div>
</div>
<!-- Antigravity CLI & Eligibility Settings (A58) -->
<div class="settings-card" style="margin-top:16px;" id="settings-agy-card">
<div class="section-card-header" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
<div>
<h2 class="settings-group-title" style="margin:0;">Параметры Antigravity CLI</h2>
<div class="setting-desc">Версия утилиты, путь к исполняемому файлу и состояние проверки доступности Google</div>
</div>
<span id="agy-eligibility-badge" class="badge">Проверка...</span>
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Версия и исполняемый файл agy</div>
<div class="setting-desc" id="agy-version-path-desc">Н/Д: определение...</div>
</div>
<div class="setting-control" style="display:flex; gap:8px;">
<button class="btn btn-secondary btn-sm" id="btn-refresh-agy-eligibility">↻ Проверить состояние</button>
<button class="btn btn-primary btn-sm" id="btn-run-agy-update" title="Обновление agy перезаписывает исполняемый файл и возвращает проверку доступности: сначала выполните обновление, затем повторно примените патч.">⬆ Обновить agy (agy update)</button>
</div>
</div>
<div class="setting-row" style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-top:8px;">
<div class="setting-desc" style="font-size:12px; color:var(--text-secondary); margin:0;">
<strong>Порядок действий:</strong> <em>Обновление agy перезаписывает исполняемый файл и возвращает проверку доступности: сначала выполните обновление, затем повторно примените патч.</em>
</div>
</div>
<div class="setting-row" style="margin-top:12px;">
<div class="setting-info">
<label class="setting-label" for="setting-agy-patch-script-path">Путь к сценарию патча владельца</label>
<div class="setting-desc">Собственный скрипт владельца для снятия проверки доступности (P0-4). Оставьте пустым, если не используется.</div>
</div>
<div class="setting-control" style="display:flex; gap:8px; width:55%;">
<input type="text" id="setting-agy-patch-script-path" class="input-text" style="flex:1;" placeholder="Н/Д: сценарий не указан">
<button class="btn btn-secondary btn-sm" id="btn-run-agy-patch-script">⚡ Запустить сценарий патча</button>
</div>
</div>
<div id="agy-eligibility-details" style="font-size:12px; color:var(--text-muted); margin-top:8px; font-family:var(--font-mono);"></div>
</div>
<!-- Hub Updates -->
<div class="settings-card" style="margin-top:16px;" id="settings-updates-card">
<h2 class="settings-group-title">Обновление Hermes Hub</h2>

View file

@ -653,6 +653,17 @@ body {
border: 1px solid var(--border-subtle);
}
.account-eligibility-warning {
margin-top: 6px;
padding: 6px 8px;
background: rgba(230, 162, 60, 0.12);
border-left: 3px solid var(--status-warning);
border-radius: var(--radius-sm);
font-size: 11px;
color: var(--status-warning);
line-height: 1.4;
}
.role-answering-status {
padding: 6px 10px;
border-radius: var(--radius-sm);

View file

@ -0,0 +1,319 @@
"""Tests for Task A58: Antigravity CLI (agy) Eligibility State Detection and Controls.
Verifies:
- Read-only machine code inspection for x86-64 and arm64 signatures.
- Detection of all 3 states: check_removed, check_active, unknown (with exact reasons).
- Strict read-only guarantee (SHA256 binary integrity preserved).
- State change event bus publishing and audit logging without continuous polling loops.
- Action handlers for run_agy_patch_script, run_agy_update, and refresh_agy_eligibility.
- Web API endpoints (/api/snapshot, /api/settings) returning agy_eligibility.
"""
from __future__ import annotations
import hashlib
import os
from pathlib import Path
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from antigravity_provider.router.agy_eligibility_service import (
STATUS_CHECK_ACTIVE,
STATUS_CHECK_REMOVED,
STATUS_UNKNOWN,
AgyEligibilityService,
)
from antigravity_provider.router.event_bus import (
EVENT_AGY_ELIGIBILITY_CHANGED,
EventBus,
)
from antigravity_provider.router.settings_service import (
get_hub_settings,
save_hub_settings,
)
from antigravity_provider.router.unified_health import EventLogService
from antigravity_provider.router.web.server import app
@pytest.fixture(autouse=True)
def reset_service():
"""Reset singleton cache before and after each test."""
service = AgyEligibilityService.get()
service.invalidate_cache()
service._last_status = None
service._last_status_label = None
service._last_sha256 = None
service._last_binary_path = None
yield
service.invalidate_cache()
service._last_status = None
service._last_status_label = None
service._last_sha256 = None
service._last_binary_path = None
def test_real_agy_binary_if_present():
"""Verify inspection against the real host binary at ~/.local/bin/agy if available."""
real_path = Path.home() / ".local" / "bin" / "agy"
if not real_path.is_file():
pytest.skip("Real agy binary not found on this host")
with open(real_path, "rb") as f:
original_bytes = f.read()
expected_sha = hashlib.sha256(original_bytes).hexdigest()
service = AgyEligibilityService.get()
res = service.check_eligibility_state(force=True, custom_binary_path=real_path)
assert res["binary_path"] == str(real_path)
assert res["binary_sha256"] == expected_sha
assert res["binary_size_bytes"] == len(original_bytes)
assert res["status"] in (STATUS_CHECK_ACTIVE, STATUS_CHECK_REMOVED, STATUS_UNKNOWN)
assert res["status_label_ru"] != ""
# Verify 100% read-only integrity: file was not modified
with open(real_path, "rb") as f:
after_bytes = f.read()
assert hashlib.sha256(after_bytes).hexdigest() == expected_sha
def test_x86_unpatched_signature_detected(tmp_path: Path):
"""P0-1 & P0-2: Unpatched x86-64 binary matches check_active."""
# Machine code: test rax,rax ; je ; cmp byte [rax+8], 0 ; jne ; call
fake_code = (
b"\x90\x90"
b"\x48\x85\xc0\x0f\x84\x0d\x02\x00\x00\x80\x78\x08\x00\x0f\x85\x03\x02\x00\x00\xe8\x48\x9c\xfd\xff"
b"\x90\x90"
)
fake_bin = tmp_path / "fake_agy_x86_orig"
fake_bin.write_bytes(fake_code)
service = AgyEligibilityService.get()
res = service.check_eligibility_state(force=True, custom_binary_path=fake_bin)
assert res["status"] == STATUS_CHECK_ACTIVE
assert "Проверка на месте" in res["status_label_ru"]
assert "Аккаунт может отклоняться" in res["detail_ru"]
assert res["binary_sha256"] == hashlib.sha256(fake_code).hexdigest()
def test_x86_patched_signature_detected(tmp_path: Path):
"""P0-1 & P0-2: Patched x86-64 binary matches check_removed."""
# Machine code: test rax,rax ; je ; test rax,rax ; nop ; jne ; call
fake_code = (
b"\x90\x90"
b"\x48\x85\xc0\x0f\x84\x0d\x02\x00\x00\x48\x85\xc0\x90\x0f\x85\x03\x02\x00\x00\xe8\x48\x9c\xfd\xff"
b"\x90\x90"
)
fake_bin = tmp_path / "fake_agy_x86_patched"
fake_bin.write_bytes(fake_code)
service = AgyEligibilityService.get()
res = service.check_eligibility_state(force=True, custom_binary_path=fake_bin)
assert res["status"] == STATUS_CHECK_REMOVED
assert "Проверка снята" in res["status_label_ru"]
assert "Патч начальной проверки" in res["detail_ru"]
assert res["binary_sha256"] == hashlib.sha256(fake_code).hexdigest()
def test_arm64_signatures_detected(tmp_path: Path):
"""P0-1 & P0-2: ARM64 original and patched detection."""
# ARM64 unpatched
arm_orig_code = b"HEADER\x00EPD_ELIGIBILITY\x00\x00\x20\x40\x39\x00\x00TRAILER"
bin_orig = tmp_path / "fake_agy_arm64_orig"
bin_orig.write_bytes(arm_orig_code)
service = AgyEligibilityService.get()
res_orig = service.check_eligibility_state(force=True, custom_binary_path=bin_orig)
assert res_orig["status"] == STATUS_CHECK_ACTIVE
assert "Проверка на месте" in res_orig["status_label_ru"]
# ARM64 patched (NOP)
arm_patch_code = b"HEADER\x00EPD_ELIGIBILITY\x00\x1f\x20\x03\xd5\x00\x00TRAILER"
bin_patch = tmp_path / "fake_agy_arm64_patch"
bin_patch.write_bytes(arm_patch_code)
service.invalidate_cache()
res_patch = service.check_eligibility_state(force=True, custom_binary_path=bin_patch)
assert res_patch["status"] == STATUS_CHECK_REMOVED
assert "Проверка снята" in res_patch["status_label_ru"]
def test_unknown_state_unsupported_binary(tmp_path: Path):
"""P0-2: Unsupported binary returns status 'unknown' with truthful reason."""
fake_code = b"Hello, this is a completely different binary without signatures."
fake_bin = tmp_path / "fake_agy_unknown"
fake_bin.write_bytes(fake_code)
service = AgyEligibilityService.get()
res = service.check_eligibility_state(force=True, custom_binary_path=fake_bin)
assert res["status"] == STATUS_UNKNOWN
assert "Н/Д: сигнатура проверки не найдена" in res["status_label_ru"]
assert "неподдерживаемая версия" in res["detail_ru"]
def test_unknown_state_missing_file(tmp_path: Path):
"""P0-2: Missing binary path returns status 'unknown'."""
missing_bin = tmp_path / "non_existent_agy_binary"
service = AgyEligibilityService.get()
res = service.check_eligibility_state(force=True, custom_binary_path=missing_bin)
assert res["status"] == STATUS_UNKNOWN
assert "Н/Д: файл не найден" in res["status_label_ru"]
def test_read_only_guarantee(tmp_path: Path):
"""Strictly guarantees that checking eligibility NEVER modifies the file."""
fake_code = b"\x48\x85\xc0\x74\x02\x80\x78\x08\x00\x75\x02"
fake_bin = tmp_path / "test_bin_readonly"
fake_bin.write_bytes(fake_code)
mtime_before = fake_bin.stat().st_mtime
sha_before = hashlib.sha256(fake_code).hexdigest()
service = AgyEligibilityService.get()
res = service.check_eligibility_state(force=True, custom_binary_path=fake_bin)
assert res["status"] == STATUS_CHECK_ACTIVE
assert fake_bin.read_bytes() == fake_code
assert fake_bin.stat().st_mtime == mtime_before
assert hashlib.sha256(fake_bin.read_bytes()).hexdigest() == sha_before
def test_state_change_event_and_audit_logging(tmp_path: Path):
"""P0-3: State transition publishes EventBus event and logs to EventLogService."""
events_received = []
def on_event(event_name, data):
events_received.append((event_name, data))
EventBus.get().subscribe(EVENT_AGY_ELIGIBILITY_CHANGED, on_event)
bin_path = tmp_path / "mutable_test_agy"
# Step 1: Initial state is check_active
code_active = b"\x48\x85\xc0\x74\x02\x80\x78\x08\x00\x75\x02"
bin_path.write_bytes(code_active)
service = AgyEligibilityService.get()
res1 = service.check_eligibility_state(force=True, custom_binary_path=bin_path)
assert res1["status"] == STATUS_CHECK_ACTIVE
# Step 2: Simulate owner running patch script -> binary transitions to check_removed
code_patched = b"\x48\x85\xc0\x74\x02\x48\x85\xc0\x90\x75\x02"
bin_path.write_bytes(code_patched)
service.invalidate_cache()
res2 = service.check_eligibility_state(force=True, custom_binary_path=bin_path)
assert res2["status"] == STATUS_CHECK_REMOVED
# EventBus must have received the transition event
assert len(events_received) == 1
assert events_received[0][0] == EVENT_AGY_ELIGIBILITY_CHANGED
assert events_received[0][1]["status"] == STATUS_CHECK_REMOVED
# EventLogService must contain security audit log
logs = EventLogService.get().get_events(category="security")
found_logs = [log for log in logs if getattr(log, "action", None) == "agy_eligibility_change" or (isinstance(log, dict) and log.get("action") == "agy_eligibility_change")]
assert len(found_logs) >= 1
last_log = found_logs[-1]
msg = getattr(last_log, "message", None) if not isinstance(last_log, dict) else last_log.get("message", "")
assert "Проверка на месте → Проверка снята" in msg
EventBus.get().unsubscribe(EVENT_AGY_ELIGIBILITY_CHANGED, on_event)
def test_action_run_agy_patch_script_not_configured():
"""P0-4: When patch script path is empty, action returns informative message."""
client = TestClient(app)
# Ensure setting is empty
save_hub_settings({"agy_patch_script_path": ""})
response = client.post("/api/action", json={"action": "run_agy_patch_script"})
assert response.status_code == 200
data = response.json()
assert data["ok"] is False
assert "Н/Д: путь к сценарию патча не указан в настройках" in data["message"]
def test_action_run_agy_patch_script_invalid_path(tmp_path: Path):
"""P0-4: When patch script file does not exist, action returns error."""
client = TestClient(app)
missing_script = tmp_path / "non_existent_patch.sh"
save_hub_settings({"agy_patch_script_path": str(missing_script)})
response = client.post("/api/action", json={"action": "run_agy_patch_script"})
assert response.status_code == 200
data = response.json()
assert data["ok"] is False
assert "Файл сценария не найден или недоступен" in data["message"]
def test_action_run_agy_patch_script_success(tmp_path: Path):
"""P0-4: Valid patch script launches in terminal and returns new eligibility state."""
client = TestClient(app)
patch_script = tmp_path / "fake_patch.sh"
patch_script.write_text("#!/bin/sh\necho Patched\n")
patch_script.chmod(0o755)
save_hub_settings({"agy_patch_script_path": str(patch_script)})
with patch("antigravity_provider.agy_subprocess.launch_terminal_task") as mock_launch:
mock_launch.return_value = (True, "Запущено", {"terminal_cmd": "xterm"})
response = client.post("/api/action", json={"action": "run_agy_patch_script"})
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert "Сценарий патча запущен" in data["message"]
assert "eligibility" in data["data"]
def test_action_run_agy_update():
"""P0-5: Action run_agy_update launches agy update in terminal."""
client = TestClient(app)
with patch("antigravity_provider.agy_subprocess.get_agy_exe", return_value="/bin/agy"), \
patch("antigravity_provider.agy_subprocess.launch_terminal_task") as mock_launch:
mock_launch.return_value = (True, "Запущено", {"terminal_cmd": "xterm"})
response = client.post("/api/action", json={"action": "run_agy_update"})
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert "Обновление agy запущено" in data["message"]
assert "eligibility" in data["data"]
def test_action_refresh_agy_eligibility():
"""Action refresh_agy_eligibility triggers cache invalidation and recheck."""
client = TestClient(app)
response = client.post("/api/action", json={"action": "refresh_agy_eligibility"})
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert "Состояние проверки agy" in data["message"]
assert "status" in data["data"]
def test_web_api_snapshot_and_settings_include_eligibility():
"""Verify /api/snapshot and /api/settings contain agy_eligibility payload."""
client = TestClient(app)
# Snapshot endpoint
snap_resp = client.get("/api/snapshot")
assert snap_resp.status_code == 200
snap_data = snap_resp.json()
assert "agy_eligibility" in snap_data
elig = snap_data["agy_eligibility"]
assert "status" in elig
assert "status_label_ru" in elig
assert "binary_sha256" in elig
# Settings endpoint
sett_resp = client.get("/api/settings")
assert sett_resp.status_code == 200
sett_data = sett_resp.json()
assert "agy_eligibility" in sett_data
assert "agy_patch_script_path" in sett_data