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 f4cc6ca..b8e4b7a 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -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} 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 e413e59..00cbc86 100644 --- a/src/antigravity_provider/router/web/server.py +++ b/src/antigravity_provider/router/web/server.py @@ -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 diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index 945c94d..780a210 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'); @@ -877,6 +913,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 = ` +
${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) {
@@ -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 = `
+