feat(agy): A58 read-only eligibility state detection and owner patcher controls
This commit is contained in:
parent
7b36538ded
commit
4860c5685e
10 changed files with 1057 additions and 19 deletions
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
329
src/antigravity_provider/router/agy_eligibility_service.py
Normal file
329
src/antigravity_provider/router/agy_eligibility_service.py
Normal 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)
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
319
tests/test_a58_agy_eligibility_state.py
Normal file
319
tests/test_a58_agy_eligibility_state.py
Normal 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
|
||||
Loading…
Reference in a new issue