diff --git a/src/antigravity_provider/agy_subprocess.py b/src/antigravity_provider/agy_subprocess.py index cf64385..3752ce2 100644 --- a/src/antigravity_provider/agy_subprocess.py +++ b/src/antigravity_provider/agy_subprocess.py @@ -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.""" diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index ffec045..5fe861d 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -1525,5 +1525,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} diff --git a/src/antigravity_provider/router/agy_eligibility_service.py b/src/antigravity_provider/router/agy_eligibility_service.py new file mode 100644 index 0000000..15aa421 --- /dev/null +++ b/src/antigravity_provider/router/agy_eligibility_service.py @@ -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) diff --git a/src/antigravity_provider/router/event_bus.py b/src/antigravity_provider/router/event_bus.py index c624889..7207972 100644 --- a/src/antigravity_provider/router/event_bus.py +++ b/src/antigravity_provider/router/event_bus.py @@ -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.""" diff --git a/src/antigravity_provider/router/settings_service.py b/src/antigravity_provider/router/settings_service.py index 8a5d650..8f8a536 100644 --- a/src/antigravity_provider/router/settings_service.py +++ b/src/antigravity_provider/router/settings_service.py @@ -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 diff --git a/src/antigravity_provider/router/web/server.py b/src/antigravity_provider/router/web/server.py index 18e69a5..dda5fdc 100644 --- a/src/antigravity_provider/router/web/server.py +++ b/src/antigravity_provider/router/web/server.py @@ -265,6 +265,21 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)): "log_file": str(paths.get_log_file()), } snap_dict["last_applied_update"] = get_last_applied_update() + 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") @@ -531,6 +546,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 diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index 75c3c20..3c61f33 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -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'); @@ -879,6 +915,37 @@ function renderAccountCard(profile) { const unassignedBadge = !isAssigned ? 'Не назначен' : ''; + 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 = `${escapeHtml(badgeText)}`; + + let warningHtml = ''; + if (agyElig.status === 'check_active') { + warningHtml = ` +
+ ⚠️ Проверка доступности Antigravity активна. Аккаунт может отклоняться Google по региону. Примените патч или настройте прокси. +
+ `; + } + agyEligibilityHtml = ` +
+ Доступность agy: + ${badgeHtml} +
+ ${warningHtml} + `; + } + return `
@@ -900,6 +967,7 @@ function renderAccountCard(profile) {
Предпочитаемые:${(profile.preferred_models || []).map(modelBrandLabel).join('')}
+ ${agyEligibilityHtml} ${renderAccountCheck(profile)} ${quotaGridHtml}
@@ -1592,14 +1660,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') + ? `
+ ⚠️ Проверка доступности Antigravity активна: аккаунты Google могут отклоняться по региону («not currently available in your location»). Примените патч в Настройках или используйте прокси. +
` + : ''; + + if (warnings.length === 0 && !agyWarnHtml) { warningsContainer.innerHTML = '
✓ Критических предупреждений и деградаций не обнаружено
'; } else { - warningsContainer.innerHTML = warnings.map((w) => ` + const regularWarnings = warnings.map((w) => `
⚠ ${escapeHtml(w.title || 'Предупреждение')}: ${escapeHtml(w.message || w)}
`).join(''); + warningsContainer.innerHTML = agyWarnHtml + regularWarnings; } } } @@ -1826,9 +1902,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 = `Версия: ${escapeHtml(ver)} • Путь: ${escapeHtml(bin)}`; + } + + 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) { @@ -1867,6 +1978,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'); @@ -1874,6 +1986,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(); @@ -2149,6 +2262,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 = ` +
+
+ Состояние проверки доступности agy: + ${escapeHtml(badgeText)} +
+
+ ${escapeHtml(agyElig.detail_ru || 'Состояние проверки доступности утилиты agy.')} +
+ ${agyElig.binary_path ? `
Файл: ${escapeHtml(agyElig.binary_path)} (v${escapeHtml(agyElig.version || 'Н/Д')})
` : ''} + ${agyElig.status === 'check_active' ? ` +
+ ⚠️ Внимание: Проверка доступности активна. Аккаунт может отклоняться Google по региону. Примените патч в Настройках или настройте прокси. +
+ ` : ''} +
+ `; + } + modelBlockHtml = renderAccountCheck(profile) + modelBlockHtml; elements.modalTitle.textContent = `Учетная запись: ${profile.display_name || profileId}`; elements.modalBody.innerHTML = ` @@ -2157,7 +2302,7 @@ function openAccountDetailsModal(profileId, isRedraw = false) {
${escapeHtml(profile.account_identity || profile.email || profileId)}
Провайдер: ${escapeHtml(profile.provider_display_name || profile.provider)} • - Тариф: ${escapeHtml(profile.plan || 'Неизвестen')} • + Тариф: ${escapeHtml(profile.plan || 'Неизвестен')} • Статус: ${escapeHtml(profile.health_label_ru || 'Работает')}
@@ -2165,6 +2310,7 @@ function openAccountDetailsModal(profileId, isRedraw = false) {
+ ${agyModalBlockHtml} ${modelBlockHtml} ${requestOptionsHtml} ${quotasHtml} diff --git a/src/antigravity_provider/router/web/static/index.html b/src/antigravity_provider/router/web/static/index.html index 96ab776..15d10be 100644 --- a/src/antigravity_provider/router/web/static/index.html +++ b/src/antigravity_provider/router/web/static/index.html @@ -665,6 +665,43 @@
+ +
+
+
+

Параметры Antigravity CLI

+
Версия утилиты, путь к исполняемому файлу и состояние проверки доступности Google
+
+ Проверка... +
+
+
+
Версия и исполняемый файл agy
+
Н/Д: определение...
+
+
+ + +
+
+
+
+ ℹ️ Порядок действий: Обновление agy перезаписывает исполняемый файл и возвращает проверку доступности: сначала выполните обновление, затем повторно примените патч. +
+
+
+
+ +
Собственный скрипт владельца для снятия проверки доступности (P0-4). Оставьте пустым, если не используется.
+
+
+ + +
+
+
+
+

Обновление Hermes Hub

diff --git a/src/antigravity_provider/router/web/static/style.css b/src/antigravity_provider/router/web/static/style.css index 6777245..5cc56d3 100644 --- a/src/antigravity_provider/router/web/static/style.css +++ b/src/antigravity_provider/router/web/static/style.css @@ -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); diff --git a/tests/test_a58_agy_eligibility_state.py b/tests/test_a58_agy_eligibility_state.py new file mode 100644 index 0000000..2a01138 --- /dev/null +++ b/tests/test_a58_agy_eligibility_state.py @@ -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