From 7136ab2878f44f292b18afd1bdae32fd7f35bec5 Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:02:40 +0700 Subject: [PATCH 01/12] =?UTF-8?q?fix(security):=20=D0=B3=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=86=D0=B0=20workspace=20=D0=B4=D0=B5=D1=80=D0=B6=D0=B8?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20=D0=BE=D0=B4=D0=B8=D0=BD=D0=B0=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=20=D0=BD=D0=B0=20Windows=20=D0=B8=20Linux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Оба красных Windows-джоба CI падали по причинам, воспроизведённым локально. 1. Инвариант A37 не держался на Windows. "rm -rf $HOME/.hermes" проходил мимо защиты: переменной HOME в окружении Windows нет, expandvars оставлял "$HOME" как есть, путь переставал быть абсолютным, склеивался с каталогом проекта и оказывался "внутри разрешённого корня". Зеркальная дыра на Linux: "%USERPROFILE%\.hermes" и "C:\Windows" проходили так же. Разбор пути сведён в один конвейер: классификация диалекта shell по самой команде (а не по системе-хозяину) -> раскрытие распознанных переменных, с разрешением HOME/USERPROFILE в домашний каталог даже когда их нет в окружении -> нормализация разделителей -> канонизация -> сравнение с защищёнными корнями. Каждый несостоявшийся шаг закрывает проход: непроверяемый путь не считается разрешённым. Через тот же конвейер пропущены validate_path, is_forbidden_path и is_inside_allowed_root. 2. UnicodeEncodeError ронял verify_multi_provider_router.py на cp1252-консоли Windows-раннера — падал вывод, не логика. Общий помощник console_encoding.force_utf8_output ставит UTF-8 на потоки и оставляет запасной путь, если перекодировать поток нельзя. Той же реализацией заменён самодельный блок в cli_commands. Проверено: скрипт проходит 10/10 под PYTHONIOENCODING=cp1252 и ascii. Новые тесты воспроизводят окружение обеих систем на любой из них и падают на прежнем guard ровно на дефекте из CI (6 failed), проходят на новом. Тесты: 739 -> 755 passed, 2 skipped, 4 deselected. ruff check . чисто. Co-Authored-By: Claude Opus 5 --- scripts/verify_multi_provider_router.py | 7 + src/antigravity_provider/console_encoding.py | 48 ++++++ .../router/cli_commands.py | 9 +- .../router/security_guard.py | 162 ++++++++++++++++-- tests/test_a37_isolation_guards.py | 80 +++++++++ 5 files changed, 284 insertions(+), 22 deletions(-) create mode 100644 src/antigravity_provider/console_encoding.py diff --git a/scripts/verify_multi_provider_router.py b/scripts/verify_multi_provider_router.py index e082fd1..919935d 100644 --- a/scripts/verify_multi_provider_router.py +++ b/scripts/verify_multi_provider_router.py @@ -18,6 +18,13 @@ for p in [ if p.is_dir() and str(p) not in sys.path: sys.path.insert(0, str(p)) +from antigravity_provider.console_encoding import force_utf8_output + +# Отчёт печатается по-русски, а консоль Windows-раннера в CI — cp1252: без этого +# первый же [PASS] с кириллицей роняет скрипт UnicodeEncodeError'ом ещё до того, +# как проверки что-либо покажут. Ставится до первого вывода. +force_utf8_output() + from antigravity_provider.router.router_config import ( RolePolicy, RouterConfig, diff --git a/src/antigravity_provider/console_encoding.py b/src/antigravity_provider/console_encoding.py new file mode 100644 index 0000000..9b9df84 --- /dev/null +++ b/src/antigravity_provider/console_encoding.py @@ -0,0 +1,48 @@ +"""Принудительный UTF-8 для потоков вывода. + +Инструменты Hermes печатают по-русски, а консоль Windows-раннера в CI работает +в cp1252. Первый же `print` с кириллицей роняет процесс UnicodeEncodeError'ом — +падает вывод, не логика. Измерено на `scripts/verify_multi_provider_router.py`: +строка `[PASS] Чистая конфигурация...` обрывала прогон с кодом 1. + +Модуль ставит UTF-8 на stdout/stderr и оставляет запасной путь на случай, когда +перекодировать поток нельзя: тогда непечатаемые символы заменяются, но процесс +продолжает работу. Вывод инструмента не должен быть причиной падения. +""" +from __future__ import annotations + +import sys +from typing import Any, Iterable + +__all__ = ["force_utf8_output"] + + +def _reconfigure(stream: Any) -> bool: + """Перевести один поток на UTF-8. True, если получилось.""" + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is None: + return False + for errors in ("strict", "backslashreplace"): + try: + reconfigure(encoding="utf-8", errors=errors) + return True + except Exception: + continue + # Поток не перекодировать (подменён, закрыт, не текстовый). Тогда хотя бы + # снимем строгость с текущей кодировки, чтобы кириллица не роняла процесс. + try: + reconfigure(errors="backslashreplace") + return True + except Exception: + return False + + +def force_utf8_output(streams: Iterable[str] = ("stdout", "stderr")) -> None: + """Перевести стандартные потоки на UTF-8; молча пропустить недоступные. + + Вызывается один раз на старте точки входа, до первого вывода. + """ + for name in streams: + stream = getattr(sys, name, None) + if stream is not None: + _reconfigure(stream) diff --git a/src/antigravity_provider/router/cli_commands.py b/src/antigravity_provider/router/cli_commands.py index 2f622a0..c1b228b 100644 --- a/src/antigravity_provider/router/cli_commands.py +++ b/src/antigravity_provider/router/cli_commands.py @@ -9,17 +9,16 @@ import time from pathlib import Path from typing import Any, Dict, List, Optional +from antigravity_provider.console_encoding import force_utf8_output from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig, load_router_config from antigravity_provider.router.health_tracker import HealthTracker from antigravity_provider.router.router_engine import RouterEngine, get_router_engine from antigravity_provider.router.adapters import get_adapter from antigravity_provider.router.profile_manager import ProfileAuthManager, mask_email, mask_id -if hasattr(sys.stdout, "reconfigure"): - try: - sys.stdout.reconfigure(encoding="utf-8") - except Exception: - pass +# Вывод CLI — по-русски, консоль Windows по умолчанию не UTF-8. Общий помощник, +# тот же, что и у скриптов проверки: одна реализация, одно поведение. +force_utf8_output() def print_router_status() -> int: diff --git a/src/antigravity_provider/router/security_guard.py b/src/antigravity_provider/router/security_guard.py index 186b7bb..7eca587 100644 --- a/src/antigravity_provider/router/security_guard.py +++ b/src/antigravity_provider/router/security_guard.py @@ -135,6 +135,117 @@ DESTRUCTIVE_COMMAND_NAMES: Set[str] = { } +# ── Единый конвейер разбора пути ──────────────────────────────── +# +# Граница вокруг агентских shell-действий обязана работать одинаково на всех +# поддерживаемых системах, иначе доказанной она не является ни на одной. +# Измерено: "rm -rf $HOME/.hermes" отклонялось на Linux и проходило на Windows, +# потому что переменной HOME в окружении Windows нет — os.path.expandvars +# оставлял "$HOME" как есть, путь переставал быть абсолютным, склеивался с +# каталогом проекта и оказывался "внутри разрешённого корня". Зеркальная дыра +# на Linux: "%USERPROFILE%\.hermes" и "C:\Windows" тоже проходили. +# +# Порядок шагов: классификация диалекта → раскрытие распознанных переменных → +# нормализация разделителей → канонизация → сравнение с корнями. Каждый шаг, +# который не удался, закрывает проход: непроверяемый путь не считается +# разрешённым. + +_HOME_VARIABLE_NAMES = frozenset({"HOME", "USERPROFILE"}) + +_VARIABLE_REFERENCE = re.compile( + r"\$\{(?P[A-Za-z_][A-Za-z0-9_]*)\}" + r"|\$(?P[A-Za-z_][A-Za-z0-9_]*)" + r"|%(?P[A-Za-z_][A-Za-z0-9_]*)%" +) + +_WINDOWS_DRIVE_PREFIX = re.compile(r"^[A-Za-z]:[\\/]") + +# Признаки записи в диалекте cmd/PowerShell: %VAR%, буква диска, разделитель "\". +_WINDOWS_DIALECT_MARKERS = re.compile(r"%[A-Za-z_][A-Za-z0-9_]*%|[A-Za-z]:[\\/]|\\[A-Za-z0-9_.]") + + +def looks_like_windows_dialect(cmd_line: str) -> bool: + """Записана ли команда в диалекте Windows. + + Диалект определяется по самой строке, а не по системе-хозяину: команду в + записи cmd могут прислать и на Linux, и разобрать её posix-правилами нельзя — + shlex съест "\" как экранирование и разделитель пути исчезнет. + """ + return bool(_WINDOWS_DIALECT_MARKERS.search(cmd_line)) + + +def expand_path_argument(raw: str) -> Tuple[str, Optional[str]]: + """Раскрыть "~" и переменные окружения обоих диалектов. + + Возвращает (раскрытая строка, причина нераскрытия | None). Имена HOME и + USERPROFILE разрешаются в домашний каталог даже тогда, когда их нет в + окружении: команда, написанная в чужом диалекте, целит ровно туда же. + Всё, что раскрыть не удалось, возвращается причиной — вызывающий обязан + закрыться, а не гадать. + """ + unresolved: List[str] = [] + + def _substitute(match: "re.Match[str]") -> str: + name = match.group("brace") or match.group("bare") or match.group("percent") + value = os.environ.get(name) + if value is None and name.upper() in _HOME_VARIABLE_NAMES: + try: + value = str(Path.home()) + except Exception: + value = None + if value is None: + unresolved.append(name) + return match.group(0) + return value + + expanded = _VARIABLE_REFERENCE.sub(_substitute, raw) + expanded = os.path.expanduser(expanded) + if expanded.startswith("~"): + # expanduser не смог определить домашний каталог: оставлять "~" внутри + # пути нельзя — он перестанет быть абсолютным и уедет внутрь проекта. + unresolved.append("~") + + if unresolved: + return expanded, "не раскрыты: " + ", ".join(sorted(set(unresolved))) + return expanded, None + + +def canonical_path(raw: Path | str, base_cwd: Optional[Path | str] = None) -> Tuple[Optional[Path], Optional[str]]: + """Привести аргумент к каноническому пути или объяснить отказ. + + Ровно одно из двух возвращаемых значений не None. Отказ — это отказ в + доступе: путь, который нельзя достоверно разрешить, нельзя и признать + находящимся внутри разрешённого корня. + """ + text = str(raw) + if not text: + return None, "пустой путь" + + expanded, unresolved_reason = expand_path_argument(text) + if unresolved_reason: + return None, f"путь '{raw}' невозможно раскрыть ({unresolved_reason})" + + normalized = expanded.replace("\\", "/") + + if _WINDOWS_DRIVE_PREFIX.match(normalized) and not Path(normalized).is_absolute(): + # Путь с буквой диска на не-Windows: канонизировать его нечем. resolve() + # припишет ему текущий каталог, и "C:/Windows" окажется внутри проекта. + return None, f"путь '{raw}' записан в диалекте другой системы и здесь не проверяем" + + candidate = Path(normalized) + if not candidate.is_absolute() and not normalized.startswith("/"): + try: + base = Path(base_cwd) if base_cwd is not None else paths.get_repo_root() + except Exception as exc: + return None, f"не определён базовый каталог для '{raw}': {exc}" + candidate = base / normalized + + try: + return candidate.resolve(), None + except Exception as exc: + return None, f"путь '{raw}' не разрешается: {exc}" + + class WorkspaceBoundaryGuard: """Enforces explicit workspace boundaries, defends credential directories, and inspects destructive operations.""" @@ -206,7 +317,11 @@ class WorkspaceBoundaryGuard: def is_inside_allowed_root(self, path: Path | str) -> bool: """Check whether the given path resolves within any allowed root.""" try: - target = Path(path).expanduser().resolve() + target, resolve_error = canonical_path(path) + if target is None: + # Путь не разрешается — считать его находящимся внутри + # разрешённого корня нельзя. + return False for root in self.get_allowed_roots(): try: target.relative_to(root) @@ -220,7 +335,9 @@ class WorkspaceBoundaryGuard: def is_forbidden_path(self, path: Path | str) -> Tuple[bool, Optional[str]]: """Check whether the path touches an unconditionally protected directory or file.""" try: - target = Path(path).expanduser().resolve() + target, resolve_error = canonical_path(path) + if target is None: + return True, resolve_error # 1. Exact match or child of forbidden directory for fpath in self.get_forbidden_paths(): if target == fpath: @@ -252,10 +369,9 @@ class WorkspaceBoundaryGuard: Returns: (is_allowed: bool, reason: str, safe_alternative: Optional[str]) """ - try: - target = Path(path).expanduser().resolve() - except Exception as exc: - return False, f"Недопустимый путь '{path}': {exc}", "Используйте стандартный относительный путь" + target, resolve_error = canonical_path(path) + if target is None: + return False, f"Недопустимый путь: {resolve_error}", "Укажите путь внутри проекта явно, без нераскрытых переменных" # Check unconditional forbidden paths for mutating/deleting operations if operation in {"delete", "write", "truncate", "move"}: @@ -290,12 +406,18 @@ class WorkspaceBoundaryGuard: # Parse command tokens if isinstance(cmd_line, list): tokens = list(cmd_line) + windows_dialect = any(looks_like_windows_dialect(str(t)) for t in tokens) else: + # Диалект берётся из самой команды: строку в записи cmd нельзя + # разбирать posix-правилами — shlex съест "\\" как экранирование, + # и разделитель пути исчезнет ещё до проверки. + windows_dialect = looks_like_windows_dialect(cmd_line) try: - # Windows and POSIX-compatible shlex split - tokens = shlex.split(cmd_line, posix=(os.name != "nt")) + tokens = shlex.split(cmd_line, posix=not windows_dialect) except Exception: tokens = cmd_line.split() + if windows_dialect: + tokens = [t[1:-1] if len(t) > 1 and t[0] == t[-1] and t[0] in "\"'" else t for t in tokens] if not tokens: return True, "OK", None @@ -310,7 +432,7 @@ class WorkspaceBoundaryGuard: # Extract target arguments (skip flags starting with - or /) targets = [] for arg in tokens[1:]: - if arg.startswith("-") or (os.name == "nt" and arg.startswith("/") and len(arg) == 2): + if arg.startswith("-") or (windows_dialect and arg.startswith("/") and len(arg) == 2): continue targets.append(arg) @@ -321,17 +443,23 @@ class WorkspaceBoundaryGuard: return False, f"Команда {cmd_name} запущена в недопустимом каталоге: {reason}", alt else: for target_arg in targets: - # Тильда и переменные окружения раскрываются ДО проверки. + # Тильда и переменные окружения раскрываются ДО проверки, и + # одинаково для обоих диалектов. # # Без этого "rm -rf ~/.hermes/agy_profiles" не считался # абсолютным путём, склеивался с каталогом проекта в путь с - # буквальным "~" внутри и признавался допустимым. Проверено: - # команда с тильдой проходила, та же команда с абсолютным - # путём отклонялась. То есть самый естественный способ - # написать опасную команду обходил защиту ровно там, ради - # чего она и делалась — на каталоге учётных данных. - expanded = os.path.expandvars(os.path.expanduser(target_arg)) - target_path = Path(expanded) if Path(expanded).is_absolute() else (base_cwd / expanded) + # буквальным "~" внутри и признавался допустимым; а + # "rm -rf $HOME/.hermes" ровно так же проходил на Windows, + # где переменной HOME в окружении нет. Самый естественный + # способ написать опасную команду обходил защиту ровно там, + # ради чего она и делалась — на каталоге учётных данных. + target_path, resolve_error = canonical_path(target_arg, base_cwd=base_cwd) + if target_path is None: + return ( + False, + f"Команда '{cmd_name}' обращается к непроверяемому пути: {resolve_error}", + "Укажите путь внутри проекта явно, без нераскрытых переменных", + ) ok, reason, alt = self.validate_path(target_path, operation="delete") if not ok: return False, f"Команда '{cmd_name}' пытается удалить недопустимый путь '{target_arg}': {reason}", alt diff --git a/tests/test_a37_isolation_guards.py b/tests/test_a37_isolation_guards.py index 731227e..8cc08f9 100644 --- a/tests/test_a37_isolation_guards.py +++ b/tests/test_a37_isolation_guards.py @@ -396,3 +396,83 @@ def test_destructive_command_with_tilde_is_rejected(monkeypatch, tmp_path): # Обычная работа внутри проекта не должна страдать. allowed, _reason, _alt = guard.validate_command("rm src/temp_file.py") assert allowed, "защита мешает штатной работе внутри проекта" + + +# ── HUB-1: граница обязана держаться одинаково на Windows и Linux ── +# +# Инвариант A37 падал только на Windows-раннере: "rm -rf $HOME/.hermes" +# проходил мимо защиты, потому что переменной HOME в окружении Windows нет. +# Прогон на одной системе этого не показывал. Тесты ниже воспроизводят +# окружение обеих систем на любой из них, поэтому дыра больше не может +# спрятаться за тем, где именно запущен CI. + +_DESTRUCTIVE_BOTH_DIALECTS = [ + "rm -rf ~/.hermes/agy_profiles", + "rm -rf ~/.ssh", + "rm -rf $HOME/.hermes", + "rm -rf ${HOME}/.hermes", + r"rm -rf %USERPROFILE%\.hermes", + r"del /f /q %USERPROFILE%\.ssh", +] + + +@pytest.fixture +def guard_env(monkeypatch, tmp_path): + """Guard с HERMES_HOME во временном каталоге — вне домашнего и вне проекта.""" + from antigravity_provider.router.security_guard import WorkspaceBoundaryGuard + + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + return WorkspaceBoundaryGuard() + + +@pytest.mark.parametrize("simulated_os", ["linux", "windows"]) +@pytest.mark.parametrize("cmd", _DESTRUCTIVE_BOTH_DIALECTS) +def test_home_directed_destruction_rejected_on_both_systems(monkeypatch, guard_env, simulated_os, cmd): + """Удаление по домашнему каталогу отклоняется в обоих окружениях. + + Окружение Windows отличается от Linux ровно тем, из-за чего защита и + расходилась: HOME не задан, домашний каталог известен через USERPROFILE. + """ + if simulated_os == "windows": + monkeypatch.delenv("HOME", raising=False) + monkeypatch.setenv("USERPROFILE", str(Path.home())) + else: + monkeypatch.setenv("HOME", str(Path.home())) + monkeypatch.delenv("USERPROFILE", raising=False) + + allowed, reason, _alt = guard_env.validate_command(cmd) + assert not allowed, f"[{simulated_os}] команда прошла мимо защиты: {cmd} ({reason})" + + +@pytest.mark.parametrize("simulated_os", ["linux", "windows"]) +def test_guard_fails_closed_on_unresolvable_argument(monkeypatch, guard_env, simulated_os): + """Нераскрытая переменная — отказ, а не пропуск. + + Раньше "$HOME/.hermes" с неизвестной переменной переставал быть абсолютным + путём, склеивался с каталогом проекта и признавался допустимым. Путь, + который нельзя разрешить, нельзя и признать безопасным. + """ + if simulated_os == "windows": + monkeypatch.delenv("HOME", raising=False) + monkeypatch.delenv("HERMES_UNSET_TARGET", raising=False) + + for cmd in ["rm -rf $HERMES_UNSET_TARGET/data", r"rm -rf %HERMES_UNSET_TARGET%\data"]: + allowed, reason, _alt = guard_env.validate_command(cmd) + assert not allowed, f"[{simulated_os}] guard не закрылся на нераскрытом пути: {cmd} ({reason})" + + +def test_foreign_dialect_absolute_path_is_not_treated_as_project_local(guard_env): + """Путь с буквой диска на Linux не должен считаться внутренним. + + Path("C:/Windows").resolve() на Linux приписывает пути текущий каталог, и + удаление системного каталога Windows выглядело как работа внутри проекта. + """ + allowed, reason, _alt = guard_env.validate_command(r"del /f /q C:\Windows\System32") + assert not allowed, f"путь чужого диалекта признан внутренним: {reason}" + + +def test_normal_work_inside_project_still_allowed(guard_env): + """Ужесточение не должно мешать штатной работе.""" + for cmd in ["rm src/temp_file.py", "rm -rf build/", "rm ./tests/tmp.log"]: + allowed, reason, _alt = guard_env.validate_command(cmd) + assert allowed, f"защита мешает штатной работе: {cmd} ({reason})" -- 2.45.2 From a3373f9f76b04b66a447a178ce8e7c02f1cf276b Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:10:20 +0700 Subject: [PATCH 02/12] =?UTF-8?q?fix(tests):=20=D0=BF=D0=BB=D0=B0=D1=82?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BF=D1=83=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B2=20=D0=BD=D0=B5=20=D0=B2=D1=8B=D0=B4?= =?UTF-8?q?=D0=B0=D1=8E=D1=82=20=D1=81=D0=B5=D0=B1=D1=8F=20=D0=B7=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=B5=D1=84=D0=B5=D0=BA=D1=82=D1=8B=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D1=83=D0=BA=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прогон на Windows-раннере показал, что причин красного CI больше двух. Четыре падения — не в продукте, а в допущениях тестов, зашитых под Linux. 1. test_a41 читал вывод скрипта в кодировке системы. Скрипт теперь пишет UTF-8, а родитель на Windows читал трубу как cp1252 и разваливался на UnicodeDecodeError, оставляя proc.stdout равным None. Кодировка задана явно с обеих сторон трубы. 2. test_p0_3_stop_running_hub знал только про ветку Linux: os.kill по списку от pgrep. На Windows процессы останавливает taskkill по списку от wmic, os.kill не вызывается — тест падал на пустом списке убитых. Инвариант же один для обеих веток: чужой процесс хаба останавливается, собственный PID не трогается. Теперь он проверяется на обеих. 3-4. Оба теста установки подсовывали bash-скрипт hermes-hub-setup.sh. На Windows выбирается HermesHubSetup.exe, и установка честно отвечала «в релизе не найден подходящий файл обновления для текущей платформы». Установщик теперь берётся под ту систему, на которой идёт прогон. Проверка сообщения об ошибке смотрит на то, назван ли код возврата, а не на склонение: ветки формулируют «код 3» и «кодом 3», инвариант один. Тесты: 755 -> 756 passed, 2 skipped, 4 deselected. ruff check . чисто. Co-Authored-By: Claude Opus 5 --- tests/test_a41_clean_install.py | 11 +- tests/test_a59_visible_update.py | 171 ++++++++++++++++++++----------- 2 files changed, 118 insertions(+), 64 deletions(-) diff --git a/tests/test_a41_clean_install.py b/tests/test_a41_clean_install.py index 256a283..fb48374 100644 --- a/tests/test_a41_clean_install.py +++ b/tests/test_a41_clean_install.py @@ -249,7 +249,14 @@ def test_p0_5_verification_script_runs_clean_and_filled(clean_a41_env): env["HERMES_HOME"] = str(hermes_home) script_path = Path(__file__).resolve().parent.parent / "scripts" / "verify_multi_provider_router.py" - proc = subprocess.run([sys.executable, str(script_path)], env=env, capture_output=True, text=True) + + # Кодировка задаётся явно с обеих сторон трубы. Скрипт печатает по-русски и + # сам переводит вывод на UTF-8; родитель же на Windows по умолчанию читает + # трубу в кодировке системы (cp1252) и разваливается на UnicodeDecodeError, + # оставляя proc.stdout равным None — падал разбор вывода, а не проверки. + run_kwargs = dict(env=env, capture_output=True, text=True, encoding="utf-8", errors="replace") + + proc = subprocess.run([sys.executable, str(script_path)], **run_kwargs) assert proc.returncode == 0, f"Verification failed on clean config: {proc.stderr}\n{proc.stdout}" assert "10/10 CHECKS PASSED" in proc.stdout @@ -259,6 +266,6 @@ def test_p0_5_verification_script_runs_clean_and_filled(clean_a41_env): AutoAssigner.assign_profile_to_role("codex-1", "manager", is_primary=True) AutoAssigner.assign_profile_to_role("ag-1", "manager", is_primary=False) - proc_filled = subprocess.run([sys.executable, str(script_path)], env=env, capture_output=True, text=True) + proc_filled = subprocess.run([sys.executable, str(script_path)], **run_kwargs) assert proc_filled.returncode == 0, f"Verification failed on filled config: {proc_filled.stderr}\n{proc_filled.stdout}" assert "10/10 CHECKS PASSED" in proc_filled.stdout diff --git a/tests/test_a59_visible_update.py b/tests/test_a59_visible_update.py index 7e80c72..822ab09 100644 --- a/tests/test_a59_visible_update.py +++ b/tests/test_a59_visible_update.py @@ -9,9 +9,11 @@ Verifies: """ from __future__ import annotations +import contextlib import io import json import os +import re import shutil import sys import tempfile @@ -249,23 +251,41 @@ def test_p0_2_sha256_mismatch_aborts_and_sets_failed_status(tmp_path, monkeypatc # ── TEST 5: P0-3 Process Isolation stop_running_hub ── +@pytest.mark.parametrize("simulated_os", ["linux", "windows"]) @pytest.mark.unit -def test_p0_3_stop_running_hub_isolates_user_and_excludes_current_pid(): - """stop_running_hub filters by current UID on Linux and never targets own PID.""" +def test_p0_3_stop_running_hub_isolates_user_and_excludes_current_pid(simulated_os): + """Чужие процессы хаба останавливаются, собственный — никогда. + + Проверяется на обеих ветках, а не на той, где случился прогон. Ветки + останавливают процессы по-разному: на Linux — os.kill по списку от pgrep, + на Windows — taskkill по списку от wmic. Тест знал только про первую и на + Windows-раннере падал на пустом списке убитых, хотя проверять надо один и + тот же инвариант — «свой PID не трогаем». + """ current_pid = os.getpid() + is_win = simulated_os == "windows" - # Mock subprocess.run for pgrep - with patch("subprocess.run") as mock_run: - # Simulate pgrep returning other PID and own PID - mock_run.return_value = MagicMock(returncode=0, stdout=f"99999 {current_pid}\n") + with patch("antigravity_provider.updater.update_manager.sys") as mock_sys: + mock_sys.platform = "win32" if is_win else "linux" - with patch("os.kill") as mock_kill: - stop_running_hub(timeout_sec=0.1) + with patch("subprocess.run") as mock_run: + # wmic и pgrep перечисляют один и тот же набор: чужой PID и свой. + mock_run.return_value = MagicMock(returncode=0, stdout=f"99999\n{current_pid}\n") - # Check that kill was called on 99999 but NEVER on current_pid - killed_pids = [call.args[0] for call in mock_kill.call_args_list] - assert 99999 in killed_pids - assert current_pid not in killed_pids, "stop_running_hub must never kill current PID" + with patch("os.kill") as mock_kill: + stop_running_hub(timeout_sec=0.1) + + if is_win: + killed_pids = [ + int(call.args[0][-1]) + for call in mock_run.call_args_list + if call.args and call.args[0] and call.args[0][0] == "taskkill" + ] + else: + killed_pids = [call.args[0] for call in mock_kill.call_args_list] + + assert 99999 in killed_pids, f"[{simulated_os}] чужой процесс хаба не остановлен: {killed_pids}" + assert current_pid not in killed_pids, f"[{simulated_os}] остановлен собственный процесс" # ── TEST 6: P0-3 apply_update_sync Rollback on Corruption ── @@ -381,40 +401,43 @@ def test_p0_4_failed_install_records_nothing_and_names_exit_code(tmp_path, monke monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1") - installer = tmp_path / "hermes-hub-setup.sh" - installer.write_bytes(b"#!/bin/bash\nexit 3\n") - sha = hashlib.sha256(installer.read_bytes()).hexdigest() + with platform_installer(tmp_path, exit_code=3) as (asset_name, installer): + sha = hashlib.sha256(installer.read_bytes()).hexdigest() - check_result = UpdateCheckResult( - update_available=True, - current_version="0.1.3", - latest_version="0.1.4", - latest_commit="deadbeefdeadbeef", - installed_commit="0000000aaaa", - assets={ - "hermes-hub-setup.sh": f"file://{installer}", - "checksums.txt": "file:///nonexistent", - }, - ) + check_result = UpdateCheckResult( + update_available=True, + current_version="0.1.3", + latest_version="0.1.4", + latest_commit="deadbeefdeadbeef", + installed_commit="0000000aaaa", + assets={ + asset_name: f"file://{installer}", + "checksums.txt": "file:///nonexistent", + }, + ) - mgr = UpdateManager() - real_download = UpdateManager._download_file + mgr = UpdateManager() + real_download = UpdateManager._download_file - def fake_download(self, url, dest, progress_cb=None): - if dest.name == "checksums.txt": - dest.write_text(f"{sha} hermes-hub-setup.sh\n", encoding="utf-8") - return - return real_download(self, url, dest, progress_cb) + def fake_download(self, url, dest, progress_cb=None): + if dest.name == "checksums.txt": + dest.write_text(f"{sha} {asset_name}\n", encoding="utf-8") + return + return real_download(self, url, dest, progress_cb) - with patch.object(UpdateManager, "_download_file", fake_download): - with patch("antigravity_provider.updater.update_manager.stop_running_hub", return_value=True): - ok, msg = mgr.install_latest_update(check_result=check_result) + with patch.object(UpdateManager, "_download_file", fake_download): + with patch("antigravity_provider.updater.update_manager.stop_running_hub", return_value=True): + ok, msg = mgr.install_latest_update(check_result=check_result) assert ok is False assert get_last_applied_update() is None, ( "После провалившейся установки записи о применённом обновлении быть не должно" ) - assert "код 3" in msg, f"Причина отказа должна называть код возврата, получено: {msg!r}" + # Проверяется, что код назван, а не как он склоняется: ветки формулируют + # по-разному («код 3» и «кодом 3»), инвариант же один. + assert re.search(r"код\w*\s+3", msg), ( + f"Причина отказа должна называть код возврата, получено: {msg!r}" + ) prog = UpdateManager.get_progress_dict() assert prog["status"] == "failed" @@ -422,6 +445,31 @@ def test_p0_4_failed_install_records_nothing_and_names_exit_code(tmp_path, monke UpdateManager._set_progress(status="idle", message="Готов к обновлению") +# ── Установщик под ту систему, на которой идёт прогон ── +# +# Ветки установки различаются: на Windows выбирается HermesHubSetup.exe и +# запускается через Popen, на Linux — hermes-hub-setup.sh через bash. Тесты +# ниже проверяют не установщик, а учёт его результата, поэтому подставляется +# тот файл, который данная система действительно выбирает. Раньше в них был +# зашит bash-скрипт, и на Windows-раннере установка отвечала «в релизе не +# найден подходящий файл обновления» — падало допущение теста, не продукт. + +@contextlib.contextmanager +def platform_installer(tmp_path, exit_code: int): + """Отдать (имя ассета, путь) и заставить установщик вернуть exit_code.""" + if sys.platform == "win32": + installer = tmp_path / "HermesHubSetup.exe" + # Содержимое не исполняется: запуск подменён, проверяется учёт кода. + installer.write_bytes(b"MZ\x90\x00 hermes hub test installer\n") + with patch("subprocess.Popen") as mock_popen: + mock_popen.return_value = MagicMock(wait=MagicMock(return_value=exit_code)) + yield "HermesHubSetup.exe", installer + else: + installer = tmp_path / "hermes-hub-setup.sh" + installer.write_bytes(f"#!/bin/bash\nexit {exit_code}\n".encode("utf-8")) + yield "hermes-hub-setup.sh", installer + + # ── TEST 10: P0-4 успешная установка запись всё-таки делает ── @pytest.mark.unit def test_p0_4_successful_install_records_previous_and_new_build(tmp_path, monkeypatch): @@ -431,35 +479,34 @@ def test_p0_4_successful_install_records_previous_and_new_build(tmp_path, monkey monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1") - installer = tmp_path / "hermes-hub-setup.sh" - installer.write_bytes(b"#!/bin/bash\nexit 0\n") - sha = hashlib.sha256(installer.read_bytes()).hexdigest() + with platform_installer(tmp_path, exit_code=0) as (asset_name, installer): + sha = hashlib.sha256(installer.read_bytes()).hexdigest() - check_result = UpdateCheckResult( - update_available=True, - current_version="0.1.3", - latest_version="0.1.4", - latest_commit="deadbeefdeadbeef", - installed_commit="0000000aaaa", - assets={ - "hermes-hub-setup.sh": f"file://{installer}", - "checksums.txt": "file:///nonexistent", - }, - ) + check_result = UpdateCheckResult( + update_available=True, + current_version="0.1.3", + latest_version="0.1.4", + latest_commit="deadbeefdeadbeef", + installed_commit="0000000aaaa", + assets={ + asset_name: f"file://{installer}", + "checksums.txt": "file:///nonexistent", + }, + ) - mgr = UpdateManager() - real_download = UpdateManager._download_file + mgr = UpdateManager() + real_download = UpdateManager._download_file - def fake_download(self, url, dest, progress_cb=None): - if dest.name == "checksums.txt": - dest.write_text(f"{sha} hermes-hub-setup.sh\n", encoding="utf-8") - return - return real_download(self, url, dest, progress_cb) + def fake_download(self, url, dest, progress_cb=None): + if dest.name == "checksums.txt": + dest.write_text(f"{sha} {asset_name}\n", encoding="utf-8") + return + return real_download(self, url, dest, progress_cb) - with patch.object(UpdateManager, "_download_file", fake_download): - with patch("antigravity_provider.updater.update_manager.stop_running_hub", return_value=True): - with patch.object(UpdateManager, "schedule_restart", return_value=(True, "перезапуск запущен")): - ok, msg = mgr.install_latest_update(check_result=check_result) + with patch.object(UpdateManager, "_download_file", fake_download): + with patch("antigravity_provider.updater.update_manager.stop_running_hub", return_value=True): + with patch.object(UpdateManager, "schedule_restart", return_value=(True, "перезапуск запущен")): + ok, msg = mgr.install_latest_update(check_result=check_result) assert ok is True rec = get_last_applied_update() -- 2.45.2 From eac8352dc2340e474a8e50352f85fae6a5ca098a Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:16:43 +0700 Subject: [PATCH 03/12] =?UTF-8?q?fix(release,web):=20=D0=B2=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=20=D0=BF=D1=80=D0=BE=D0=BF=D1=83=D1=81=D0=BA=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B2=D1=81=D1=91=20=D0=BF=D0=BE=D0=B4=D1=80?= =?UTF-8?q?=D1=8F=D0=B4,=20/api/action=20=E2=80=94=20=D0=BC=D0=B5=D0=B6?= =?UTF-8?q?=D1=81=D0=B0=D0=B9=D1=82=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 аудита, каждый сначала подтверждён исполнением, а не принят со слов. 1. Release Gate объявлял проверку хеша, которой не было. Печаталась строка PACKAGE_HASH_VERIFIED=True при том, что hashlib в scripts/release_gate.py не вызывался ни разу: скачивались байты 0-10 через заголовок Range, и этого хватало, чтобы счесть хеш проверенным. «Проверенным ассетом» при этом оказывался первый в списке — checksums.txt, а не пакет. 2. Ворота публикации были fail-open. Измерено в трёх условиях: полный обрыв сети -> PASS, манифест 404 -> PASS, пакет 404 -> PASS. Ворота пропускали релиз при любом исходе, включая полное отсутствие релиза. Разделено на офлайновую часть (проверки 1-7: версии, тесты, updater, статика, секреты, список разрешённых адресов) и Publication Gate: релиз есть, ассеты есть, пакет скачан ЦЕЛИКОМ, SHA-256 сошёлся с опубликованным checksums.txt. Публикационные ворота блокируют в режиме публикации (--publication или HERMES_RELEASE_PUBLICATION_GATE=1); в обычном прогоне CI, где релиза для ветки нет и быть не должно, результат сообщается как есть и не блокирует. Неизмеренное называется причиной, а не выдаётся за проверенное. Проверено на живом релизе v0.1.3-b1: два пакета скачаны целиком, хеши сошлись. 3. POST /api/action на loopback принимал межсайтовые запросы. Токен там не требуется, а действие меняет состояние: удаляет учётные данные, чистит аккаунты, переключает маршрутизацию, запускает входы OAuth. CORS от этого не защищает — он мешает прочитать ответ, а не отправить запрос. Измерено на конфигурации по умолчанию: POST с Content-Type text/plain уходит кросс-сайтом без предварительного запроса, request.json() разбирает тело независимо от Content-Type, и запрос с Origin чужого сайта без токена доходил до исполнителя действий. Проверяется Sec-Fetch-Site, при его отсутствии — Origin против адреса запроса. Собственный интерфейс, адресная строка и не-браузерные клиенты работают как раньше. Защита распространена на все пять небезопасных методов, не только на /api/action. 4. pricing fallback: safe_load вместо safe_dump. dump сериализовал текст обратно в строку, проверка isinstance(data, dict) не выполнялась никогда, таблица цен не загружалась ни разу, а except это глушил. 5. Симуляция Linux в тесте stop_running_hub падала на Windows: os.getuid там не существует. Тесты: 756 -> 776 passed, 2 skipped, 4 deselected. ruff check . чисто. Co-Authored-By: Claude Opus 5 --- scripts/release_gate.py | 230 ++++++++++++------ .../router/telemetry_service.py | 6 +- src/antigravity_provider/router/web/server.py | 88 ++++++- tests/test_a37_isolation_guards.py | 67 +++++ tests/test_a59_visible_update.py | 5 +- tests/test_p0_release_gate.py | 160 ++++++++++++ 6 files changed, 480 insertions(+), 76 deletions(-) diff --git a/scripts/release_gate.py b/scripts/release_gate.py index 2ec0460..7530f31 100644 --- a/scripts/release_gate.py +++ b/scripts/release_gate.py @@ -204,88 +204,179 @@ def check_security_zero_secrets() -> tuple[bool, str]: return True, "Zero secret files, live tokens, or obfuscated secret assignments in src/" -def check_production_update_feed() -> tuple[bool, str]: - """Live verification of public release feed manifest and package URL.""" +# ═══════════════════════════════════════════════════════════════ +# Publication Gate +# ═══════════════════════════════════════════════════════════════ +# +# Проверка публикации отделена от офлайновой части, потому что раньше они были +# смешаны и обе были беззубыми. Измерено на прежней реализации: +# - при полном обрыве сети возвращался PASS ("check skipped"); +# - при 404 на манифест возвращался PASS ("not yet published"); +# - при 404 на пакет возвращался PASS ("pending upload"); +# - при живом пакете печаталось PACKAGE_HASH_VERIFIED=True, хотя hashlib в +# файле не вызывался ни разу: скачивались байты 0-10 через заголовок Range, +# и этого хватало, чтобы объявить хеш проверенным. +# То есть ворота публикации пропускали релиз при любом исходе, включая полное +# отсутствие релиза. +# +# Теперь: офлайновые проверки (1-6) блокируют всегда; публикация проверяется +# по-настоящему — релиз есть, ассеты есть, пакет скачан целиком, SHA-256 +# сошёлся с опубликованным. Блокирует она в режиме публикации (--publication +# или HERMES_RELEASE_PUBLICATION_GATE=1); в обычном прогоне CI, где релиза для +# ветки нет и быть не должно, результат сообщается как есть и не блокирует. +# Неизмеренное называется "Н/Д" с причиной, а не выдаётся за проверенное. + +PUBLICATION_MODE_ENV = "HERMES_RELEASE_PUBLICATION_GATE" + +# Имена ассетов-установщиков; совпадают с выбором в update_manager. +PACKAGE_ASSET_NAMES = ("HermesHubSetup.exe", "hermes-hub-setup.sh", "install-linux.sh") +CHECKSUMS_ASSET_NAME = "checksums.txt" + +# Пакет качается целиком, поэтому размер ограничен: подставленный гигантский +# ассет не должен превращать ворота в отказ в обслуживании самим себе. +MAX_PACKAGE_BYTES = 512 * 1024 * 1024 + + +def is_publication_mode() -> bool: + """Требуется ли блокирующая проверка публикации.""" + return "--publication" in sys.argv or os.environ.get(PUBLICATION_MODE_ENV, "") == "1" + + +def _http_get(url: str, timeout: int = 30): import urllib.request - import urllib.error + req = urllib.request.Request( + url, headers={"User-Agent": f"HermesHub-ReleaseGate/{__version__}"} + ) + return urllib.request.urlopen(req, timeout=timeout) + + +def _download_and_hash(url: str) -> tuple[str, int]: + """Скачать поток целиком и посчитать SHA-256. Никаких частичных диапазонов.""" + import hashlib + digest = hashlib.sha256() + size = 0 + with _http_get(url, timeout=120) as resp: + while True: + chunk = resp.read(1024 * 256) + if not chunk: + break + size += len(chunk) + if size > MAX_PACKAGE_BYTES: + raise ValueError(f"пакет превышает {MAX_PACKAGE_BYTES} байт") + digest.update(chunk) + return digest.hexdigest(), size + + +def _parse_checksums(text: str) -> dict[str, str]: + """Разобрать строки вида ' <имя файла>'.""" + table: dict[str, str] = {} + for line in text.splitlines(): + parts = line.strip().split() + if len(parts) >= 2 and re.fullmatch(r"[0-9a-fA-F]{64}", parts[0]): + table[parts[-1].lstrip("*")] = parts[0].lower() + return table + + +def check_offline_update_contract() -> tuple[bool, str]: + """Офлайновая часть: адрес обновления входит в список разрешённых.""" from antigravity_provider.updater.update_manager import DEFAULT_UPDATE_URL, is_allowed_update_host if not is_allowed_update_host(DEFAULT_UPDATE_URL): - return False, f"Default update URL host not in allowlist: {DEFAULT_UPDATE_URL}" + return False, f"Адрес обновления вне списка разрешённых: {DEFAULT_UPDATE_URL}" + return True, f"Адрес обновления в списке разрешённых: {DEFAULT_UPDATE_URL}" + +def check_publication_gate() -> tuple[bool, str]: + """Релиз опубликован, ассеты на месте, SHA-256 пакета сошёлся. + + В режиме публикации любой недостижимый шаг — отказ. Вне его отказ не + блокирует релиз, но и не выдаётся за успех. + """ + import urllib.error + from antigravity_provider.updater.update_manager import DEFAULT_UPDATE_URL + + blocking = is_publication_mode() + + def verdict(ok: bool, msg: str) -> tuple[bool, str]: + if ok: + return True, msg + if blocking: + return False, msg + return True, f"[НЕ БЛОКИРУЕТ: режим публикации не запрошен] {msg}" + + # 1. Манифест релиза try: - req = urllib.request.Request( - DEFAULT_UPDATE_URL, - headers={"User-Agent": f"HermesHub-ReleaseGate/{__version__}"} - ) - with urllib.request.urlopen(req, timeout=6) as resp: - if resp.status == 200: - data = json.loads(resp.read().decode("utf-8-sig")) - p_ver = data.get("version") or data.get("tag_name", "").lstrip("v") - p_url = data.get("package_url") - if not p_url and data.get("assets"): - p_url = data["assets"][0].get("browser_download_url") - if not p_url: - p_url = data.get("html_url") or DEFAULT_UPDATE_URL - - if not p_ver: - return False, "Public update manifest is missing version or tag_name" - - # Verify package URL reachability - pkg_live = False - pkg_status = "UNKNOWN" - try: - head_req = urllib.request.Request( - p_url, - headers={"User-Agent": f"HermesHub-ReleaseGate/{__version__}"} - ) - # Use Range header to avoid downloading huge binaries - head_req.add_header("Range", "bytes=0-10") - with urllib.request.urlopen(head_req, timeout=6) as pkg_resp: - if pkg_resp.status in (200, 206, 302): - pkg_live = True - pkg_status = "PACKAGE_LIVE" - except urllib.error.HTTPError as pkg_he: - if pkg_he.code == 404: - pkg_status = "PENDING_RELEASE_UPLOAD_404" - else: - pkg_status = f"HTTP_{pkg_he.code}" - except Exception as pkg_ex: - pkg_status = f"CHECK_SKIPPED_{pkg_ex}" - - manifest_live = True - package_live = False - hash_verified = False - - if pkg_live: - package_live = True - # If package is live, verify hash on partial bytes or full stream - hash_verified = True - return True, f"[MANIFEST_LIVE=True, PACKAGE_LIVE=True, PACKAGE_HASH_VERIFIED=True] Manifest live (v{p_ver}) and release asset verified at {p_url}" - elif pkg_status == "PENDING_RELEASE_UPLOAD_404": - return True, ( - f"[MANIFEST_LIVE=True, PACKAGE_LIVE=False (Pending Upload 404), PACKAGE_HASH_VERIFIED=Offline Validated] " - f"Manifest is live (v{p_ver}), release zip ready for GitHub Release asset upload. Offline updater tests passed." - ) - else: - return True, ( - f"[MANIFEST_LIVE=True, PACKAGE_LIVE=False ({pkg_status}), PACKAGE_HASH_VERIFIED=Offline Validated] " - f"Manifest live (v{p_ver}). Offline updater tests passed." - ) - + with _http_get(DEFAULT_UPDATE_URL) as resp: + if resp.status != 200: + return verdict(False, f"Манифест релиза ответил HTTP {resp.status}") + data = json.loads(resp.read().decode("utf-8-sig")) except urllib.error.HTTPError as he: - if he.code == 404: - return True, f"[MANIFEST_LIVE=False, PACKAGE_LIVE=False] Public manifest not yet published (HTTP 404). Offline updater tests passed." - return False, f"HTTP Error checking update feed: {he}" + return verdict(False, f"Манифест релиза недоступен: HTTP {he.code} ({DEFAULT_UPDATE_URL})") except Exception as exc: - return True, f"[MANIFEST_LIVE=Unknown, PACKAGE_LIVE=Unknown] Public feed check skipped ({exc}). Offline updater tests passed." + return verdict(False, f"Манифест релиза недоступен: {type(exc).__name__}: {exc}") - return True, "Production update feed verified" + version = data.get("version") or str(data.get("tag_name", "")).lstrip("v") + if not version: + return verdict(False, "В манифесте релиза нет ни version, ни tag_name") + + # 2. Ассеты + assets: dict[str, str] = {} + for asset in data.get("assets") or []: + name = asset.get("name") + url = asset.get("browser_download_url") + if name and url: + assets[name] = url + if not assets and data.get("package_url"): + assets[Path(data["package_url"]).name] = data["package_url"] + + if not assets: + return verdict(False, f"У релиза v{version} нет ни одного ассета") + + packages = [n for n in PACKAGE_ASSET_NAMES if n in assets] + if not packages: + return verdict( + False, + f"У релиза v{version} нет ни одного пакета установки " + f"{PACKAGE_ASSET_NAMES}; опубликованы: {sorted(assets)}", + ) + + # 3. Опубликованные контрольные суммы + if CHECKSUMS_ASSET_NAME not in assets: + return verdict(False, f"У релиза v{version} нет {CHECKSUMS_ASSET_NAME}: сверять хеш не с чем") + try: + with _http_get(assets[CHECKSUMS_ASSET_NAME]) as resp: + published = _parse_checksums(resp.read().decode("utf-8", errors="replace")) + except Exception as exc: + return verdict(False, f"{CHECKSUMS_ASSET_NAME} не скачивается: {type(exc).__name__}: {exc}") + if not published: + return verdict(False, f"{CHECKSUMS_ASSET_NAME} не содержит ни одной строки с SHA-256") + + # 4. Полное скачивание и сверка хеша каждого пакета + verified = [] + for name in packages: + expected = published.get(name) + if not expected: + return verdict(False, f"Для {name} нет строки в {CHECKSUMS_ASSET_NAME}") + try: + actual, size = _download_and_hash(assets[name]) + except Exception as exc: + return verdict(False, f"{name} не скачивается целиком: {type(exc).__name__}: {exc}") + if actual != expected: + return verdict(False, f"SHA-256 {name} не сошёлся: опубликован {expected}, посчитан {actual}") + verified.append(f"{name} ({size} байт)") + + return True, ( + f"[RELEASE_LIVE=True, PACKAGES={len(verified)}, PACKAGE_HASH_VERIFIED=True] " + f"Релиз v{version}: пакеты скачаны целиком и сверены с {CHECKSUMS_ASSET_NAME} — " + + ", ".join(verified) + ) def run_release_gate(): print("=" * 70) print(f" Hermes Hub — Release Gate Verification Suite (Target: v{__version__})") + mode = "публикация (проверки 1-8 блокируют)" if is_publication_mode() else "офлайн (блокируют 1-7)" + print(f" Режим: {mode}") print("=" * 70) checks = [ @@ -295,7 +386,8 @@ def run_release_gate(): ("4. Full Offline Pytest Suite", "[INTEGRATION VERIFIED]", check_full_test_suite), ("5. Zero Hardcoded Developer Paths", "[STATIC VERIFIED]", check_zero_hardcoded_paths), ("6. Zero Credentials & AST Secret Scan", "[SECURITY VERIFIED]", check_security_zero_secrets), - ("7. Public Production Update Feed", "[LIVE STATUS]", check_production_update_feed), + ("7. Update Contract (offline)", "[STATIC VERIFIED]", check_offline_update_contract), + ("8. Publication Gate", "[LIVE VERIFIED]", check_publication_gate), ] all_passed = True diff --git a/src/antigravity_provider/router/telemetry_service.py b/src/antigravity_provider/router/telemetry_service.py index 05d7e21..a26b9d5 100644 --- a/src/antigravity_provider/router/telemetry_service.py +++ b/src/antigravity_provider/router/telemetry_service.py @@ -161,7 +161,11 @@ class TelemetryService: if p.is_file(): try: import yaml - data = yaml.safe_dump(p.read_text(encoding="utf-8")) + # safe_load, а не safe_dump: dump сериализует текст обратно + # в строку, isinstance(data, dict) никогда не выполнялось, и + # таблица цен из pricing.yaml не загружалась ни разу. Тихо: + # ошибку глушил except. + data = yaml.safe_load(p.read_text(encoding="utf-8")) if isinstance(data, dict) and "pricing" in data: self._pricing_table = dict(data["pricing"]) return diff --git a/src/antigravity_provider/router/web/server.py b/src/antigravity_provider/router/web/server.py index dda5fdc..0371e3b 100644 --- a/src/antigravity_provider/router/web/server.py +++ b/src/antigravity_provider/router/web/server.py @@ -7,6 +7,7 @@ import time import dataclasses import logging from typing import Any, Dict, List, Optional +from urllib.parse import urlparse from antigravity_provider import paths from antigravity_provider.version import __version__ @@ -105,6 +106,63 @@ def get_auth_token(x_hub_token: str = Header(None)) -> bool: raise HTTPException(status_code=401, detail="Invalid X-Hub-Token") return True +# Небезопасные методы принимаются только от собственного интерфейса. +# +# На loopback токен не требуется вовсе (см. get_auth_token), а POST /api/action +# меняет состояние: удаляет учётные данные, чистит аккаунты, переключает +# маршрутизацию, запускает входы OAuth. CORS от этого не защищает — он мешает +# ПРОЧИТАТЬ ответ, а не отправить запрос. +# +# Проверено на конфигурации по умолчанию (web_api_host=127.0.0.1): POST с +# Content-Type: text/plain уходит кросс-сайтом без предварительного запроса +# (простой запрос по правилам CORS), а request.json() разбирает тело +# независимо от Content-Type. Запрос с Origin: https://evil.example.com и без +# токена доходил до исполнителя действий — отвечало уже само действие. +# +# Проверяется Sec-Fetch-Site, а при его отсутствии — Origin против адреса, по +# которому пришёл запрос. Отсутствие обоих заголовков означает не-браузерного +# клиента (curl, собственный CLI): браузер на кросс-сайтовый POST заголовок +# Origin ставит обязательно, поэтому его отсутствие подделкой не является. +_SAME_SITE_FETCH_VALUES = {"same-origin", "none"} + + +def _configured_allowed_origins() -> set: + raw = str(_web_settings().get("web_api_allowed_origins", "")).split(",") + return {o.strip() for o in raw if o.strip() and o.strip() != "*"} + + +def require_same_origin(request: Request) -> bool: + """Отклонить межсайтовый небезопасный запрос до того, как он что-то изменит.""" + fetch_site = (request.headers.get("sec-fetch-site") or "").strip().lower() + if fetch_site: + if fetch_site in _SAME_SITE_FETCH_VALUES: + return True + if request.headers.get("origin", "") in _configured_allowed_origins(): + return True + raise HTTPException( + status_code=403, + detail=f"Межсайтовый запрос отклонён (Sec-Fetch-Site: {fetch_site})", + ) + + origin = request.headers.get("origin") + if not origin: + return True + + host = (request.headers.get("host") or "").strip().lower() + try: + origin_host = urlparse(origin).netloc.strip().lower() + except Exception: + origin_host = "" + if origin_host and host and origin_host == host: + return True + if origin in _configured_allowed_origins(): + return True + raise HTTPException( + status_code=403, + detail="Origin не совпадает с адресом хаба: межсайтовый запрос отклонён", + ) + + # Коммит и время запуска СНИМАЮТСЯ ОДИН РАЗ, при старте процесса. # # get_installed_commit() читает манифест с диска при каждом вызове, поэтому @@ -283,7 +341,11 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)): return JSONResponse(content=jsonable_encoder(snap_dict)) @app.post("/api/action") -async def handle_action(request: Request, authorized: bool = Depends(get_auth_token)): +async def handle_action( + request: Request, + authorized: bool = Depends(get_auth_token), + same_origin: bool = Depends(require_same_origin), +): try: data = await request.json() except Exception: @@ -356,7 +418,11 @@ def get_skills_endpoint(authorized: bool = Depends(get_auth_token)): @app.post("/api/skills/assign") -async def assign_skill_endpoint(request: Request, authorized: bool = Depends(get_auth_token)): +async def assign_skill_endpoint( + request: Request, + authorized: bool = Depends(get_auth_token), + same_origin: bool = Depends(require_same_origin), +): """Assign a skill to a specific subagent.""" from antigravity_provider.router.skills_service import SkillsService try: @@ -377,7 +443,11 @@ async def assign_skill_endpoint(request: Request, authorized: bool = Depends(get @app.post("/api/skills/unassign") -async def unassign_skill_endpoint(request: Request, authorized: bool = Depends(get_auth_token)): +async def unassign_skill_endpoint( + request: Request, + authorized: bool = Depends(get_auth_token), + same_origin: bool = Depends(require_same_origin), +): """Remove an assigned skill from a subagent.""" from antigravity_provider.router.skills_service import SkillsService try: @@ -406,7 +476,11 @@ def get_skills_usage_endpoint(authorized: bool = Depends(get_auth_token)): @app.post("/api/skills/diagnose") -async def diagnose_skill_endpoint(request: Request, authorized: bool = Depends(get_auth_token)): +async def diagnose_skill_endpoint( + request: Request, + authorized: bool = Depends(get_auth_token), + same_origin: bool = Depends(require_same_origin), +): """Run SkillDoctor diagnostics on a skill by name, filepath, or raw content.""" from antigravity_provider.router.skills_service import SkillsService try: @@ -456,7 +530,11 @@ def get_compression_history_endpoint(limit: int = 20, authorized: bool = Depends @app.post("/api/compression/test") -async def test_compression_endpoint(request: Request, authorized: bool = Depends(get_auth_token)): +async def test_compression_endpoint( + request: Request, + authorized: bool = Depends(get_auth_token), + same_origin: bool = Depends(require_same_origin), +): """Execute test context compression on synthetic benchmark prompt.""" from antigravity_provider.router.settings_service import get_hub_settings from antigravity_provider.router.local_supervisor import LocalSupervisor diff --git a/tests/test_a37_isolation_guards.py b/tests/test_a37_isolation_guards.py index 8cc08f9..afb3e12 100644 --- a/tests/test_a37_isolation_guards.py +++ b/tests/test_a37_isolation_guards.py @@ -476,3 +476,70 @@ def test_normal_work_inside_project_still_allowed(guard_env): for cmd in ["rm src/temp_file.py", "rm -rf build/", "rm ./tests/tmp.log"]: allowed, reason, _alt = guard_env.validate_command(cmd) assert allowed, f"защита мешает штатной работе: {cmd} ({reason})" + + +# ── HUB-1: небезопасные методы не принимаются с чужой страницы ── + + +@pytest.fixture +def loopback_client(): + """Клиент при конфигурации по умолчанию: web_api_host=127.0.0.1, токен не нужен.""" + import antigravity_provider.router.web.server as srv + + with patch.object(srv, "_web_settings", return_value={"web_api_host": "127.0.0.1"}): + yield TestClient(app) + + +_UNSAFE_ENDPOINTS = [ + ("/api/action", '{"action": "clear_accounts", "data": {}}'), + ("/api/skills/assign", '{"skill": "x", "profile": "y"}'), + ("/api/skills/unassign", '{"skill": "x", "profile": "y"}'), + ("/api/skills/diagnose", '{"skill": "x"}'), + ("/api/compression/test", '{"text": "x"}'), +] + + +@pytest.mark.parametrize("path, body", _UNSAFE_ENDPOINTS) +def test_cross_site_post_is_rejected(loopback_client, path, body): + """Межсайтовый POST отклоняется до того, как что-либо изменит. + + На loopback токен не требуется, а действия меняют состояние: удаляют + учётные данные, чистят аккаунты, переключают маршрутизацию. CORS от этого + не защищает — он мешает прочитать ответ, а не отправить запрос. Измерено: + POST с Content-Type text/plain уходит кросс-сайтом без предварительного + запроса, request.json() разбирает тело независимо от Content-Type, и + запрос с чужим Origin доходил до исполнителя действий. + """ + headers = { + "Content-Type": "text/plain;charset=UTF-8", + "Origin": "https://evil.example.com", + "Sec-Fetch-Site": "cross-site", + } + res = loopback_client.post(path, content=body, headers=headers) + assert res.status_code == 403, f"{path} принял межсайтовый запрос: HTTP {res.status_code}" + + +@pytest.mark.parametrize("path, body", _UNSAFE_ENDPOINTS) +def test_cross_site_post_rejected_without_fetch_metadata(loopback_client, path, body): + """Браузер без Sec-Fetch-* всё равно ставит Origin — по нему и отклоняем.""" + headers = {"Content-Type": "text/plain", "Origin": "https://evil.example.com"} + res = loopback_client.post(path, content=body, headers=headers) + assert res.status_code == 403, f"{path} принял запрос с чужим Origin: HTTP {res.status_code}" + + +@pytest.mark.parametrize( + "label, headers", + [ + ("собственный интерфейс", {"Origin": "http://testserver", "Sec-Fetch-Site": "same-origin"}), + ("адресная строка", {"Sec-Fetch-Site": "none"}), + ("не-браузерный клиент", {}), + ], +) +def test_own_interface_and_cli_still_work(loopback_client, label, headers): + """Защита не должна мешать собственному интерфейсу и не-браузерным клиентам.""" + res = loopback_client.post( + "/api/action", + json={"action": "___нет_такого___", "data": {}}, + headers=headers, + ) + assert res.status_code != 403, f"{label} отклонён межсайтовой защитой" diff --git a/tests/test_a59_visible_update.py b/tests/test_a59_visible_update.py index 822ab09..bd08e7d 100644 --- a/tests/test_a59_visible_update.py +++ b/tests/test_a59_visible_update.py @@ -268,7 +268,10 @@ def test_p0_3_stop_running_hub_isolates_user_and_excludes_current_pid(simulated_ with patch("antigravity_provider.updater.update_manager.sys") as mock_sys: mock_sys.platform = "win32" if is_win else "linux" - with patch("subprocess.run") as mock_run: + # На Windows os.getuid не существует; ветка Linux падала бы на нём в + # общий except и возвращала «остановлено» никого не остановив. create=True + # позволяет подставить его там, где его нет. + with patch("os.getuid", return_value=1000, create=True), patch("subprocess.run") as mock_run: # wmic и pgrep перечисляют один и тот же набор: чужой PID и свой. mock_run.return_value = MagicMock(returncode=0, stdout=f"99999\n{current_pid}\n") diff --git a/tests/test_p0_release_gate.py b/tests/test_p0_release_gate.py index 5b1b2b0..eeef7cf 100644 --- a/tests/test_p0_release_gate.py +++ b/tests/test_p0_release_gate.py @@ -461,3 +461,163 @@ def test_s4_secret_scanner_ast_detection(tmp_path): clean_file.write_text('def hello(): return "world"\n', encoding="utf-8") v3 = scan_file_for_secrets(clean_file) assert len(v3) == 0 + + +# ── HUB-1: ворота публикации не пропускают релиз при любом исходе ── + + +def _load_release_gate(): + import importlib + import sys + scripts_dir = str(Path(__file__).resolve().parent.parent / "scripts") + if scripts_dir not in sys.path: + sys.path.insert(0, scripts_dir) + import release_gate + importlib.reload(release_gate) + return release_gate + + +@pytest.mark.unit +@pytest.mark.parametrize( + "failure, expected_in_message", + [ + ("network", "недоступен"), + ("http_404", "404"), + ("no_assets", "ассет"), + ("no_checksums", "checksums.txt"), + ("hash_mismatch", "не сошёлся"), + ], +) +def test_publication_gate_blocks_instead_of_failing_open(monkeypatch, failure, expected_in_message): + """Недостижимая публикация — отказ, а не PASS. + + Измерено на прежней реализации: при обрыве сети, при 404 на манифест и при + 404 на пакет возвращался PASS. Ворота пропускали релиз при любом исходе, + включая полное отсутствие релиза, а строка PACKAGE_HASH_VERIFIED=True + печаталась при том, что hashlib в файле не вызывался ни разу — хеш был + объявлен проверенным после чтения одиннадцати байт через заголовок Range. + """ + import urllib.error + release_gate = _load_release_gate() + monkeypatch.setenv(release_gate.PUBLICATION_MODE_ENV, "1") + monkeypatch.setattr(release_gate.sys, "argv", ["release_gate.py"]) + + manifest = { + "tag_name": "v9.9.9", + "assets": [ + {"name": "HermesHubSetup.exe", "browser_download_url": "https://example.invalid/setup.exe"}, + {"name": "checksums.txt", "browser_download_url": "https://example.invalid/checksums.txt"}, + ], + } + if failure == "no_assets": + manifest["assets"] = [] + if failure == "no_checksums": + manifest["assets"] = [manifest["assets"][0]] + + class _Resp: + status = 200 + + def __init__(self, payload: bytes): + self._payload = payload + + def read(self, *_a): + payload, self._payload = self._payload, b"" + return payload + + def __enter__(self): + return self + + def __exit__(self, *_a): + return False + + def fake_get(url, timeout=30): + if failure == "network": + raise urllib.error.URLError("сети нет") + if failure == "http_404": + raise urllib.error.HTTPError(url, 404, "Not Found", {}, None) + if url.endswith("checksums.txt"): + return _Resp(b"%s HermesHubSetup.exe\n" % (b"a" * 64)) + if url.endswith("setup.exe"): + return _Resp(b"payload-with-a-different-hash") + return _Resp(json.dumps(manifest).encode("utf-8")) + + monkeypatch.setattr(release_gate, "_http_get", fake_get) + + ok, msg = release_gate.check_publication_gate() + assert ok is False, f"ворота пропустили релиз при отказе '{failure}': {msg}" + assert expected_in_message in msg, f"причина отказа не названа: {msg!r}" + + +@pytest.mark.unit +def test_publication_gate_hashes_the_whole_package(monkeypatch): + """Успех объявляется только после полного скачивания и сверки SHA-256.""" + import hashlib + release_gate = _load_release_gate() + monkeypatch.setenv(release_gate.PUBLICATION_MODE_ENV, "1") + monkeypatch.setattr(release_gate.sys, "argv", ["release_gate.py"]) + + package = b"hermes hub installer payload" + real_sha = hashlib.sha256(package).hexdigest() + read_bytes = {"total": 0} + + class _Resp: + status = 200 + + def __init__(self, payload: bytes, count: bool = False): + self._payload = payload + self._count = count + + def read(self, *_a): + payload, self._payload = self._payload, b"" + if self._count: + read_bytes["total"] += len(payload) + return payload + + def __enter__(self): + return self + + def __exit__(self, *_a): + return False + + manifest = { + "tag_name": "v9.9.9", + "assets": [ + {"name": "HermesHubSetup.exe", "browser_download_url": "https://example.invalid/setup.exe"}, + {"name": "checksums.txt", "browser_download_url": "https://example.invalid/checksums.txt"}, + ], + } + + def fake_get(url, timeout=30): + if url.endswith("checksums.txt"): + return _Resp(f"{real_sha} HermesHubSetup.exe\n".encode("utf-8")) + if url.endswith("setup.exe"): + return _Resp(package, count=True) + return _Resp(json.dumps(manifest).encode("utf-8")) + + monkeypatch.setattr(release_gate, "_http_get", fake_get) + + ok, msg = release_gate.check_publication_gate() + assert ok is True, msg + assert "PACKAGE_HASH_VERIFIED=True" in msg + assert read_bytes["total"] == len(package), ( + f"пакет должен быть прочитан целиком, прочитано {read_bytes['total']} из {len(package)}" + ) + + +@pytest.mark.unit +def test_offline_run_does_not_claim_publication_verified(monkeypatch): + """Без режима публикации отсутствие релиза не блокирует, но и не врёт.""" + import urllib.error + release_gate = _load_release_gate() + monkeypatch.delenv(release_gate.PUBLICATION_MODE_ENV, raising=False) + monkeypatch.setattr(release_gate.sys, "argv", ["release_gate.py"]) + + def fake_get(url, timeout=30): + raise urllib.error.HTTPError(url, 404, "Not Found", {}, None) + + monkeypatch.setattr(release_gate, "_http_get", fake_get) + + ok, msg = release_gate.check_publication_gate() + assert ok is True, "обычный прогон CI не должен блокироваться отсутствием релиза" + assert "НЕ БЛОКИРУЕТ" in msg + assert "PACKAGE_HASH_VERIFIED=True" not in msg, "непроверенное не должно объявляться проверенным" -- 2.45.2 From 61e79333347cfbc9b4bf1fd948a83b580d1a618e Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:20:34 +0700 Subject: [PATCH 04/12] =?UTF-8?q?fix(release):=20=D0=BE=D1=82=D1=87=D1=91?= =?UTF-8?q?=D1=82=20=D0=B2=D0=BE=D1=80=D0=BE=D1=82=20=D0=BD=D0=B5=20=D1=80?= =?UTF-8?q?=D0=BE=D0=BD=D1=8F=D0=B5=D1=82=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=D0=BD=20=D0=BD=D0=B0=20cp1252-=D0=BA=D0=BE=D0=BD=D1=81=D0=BE?= =?UTF-8?q?=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Шаг Release Gate падал UnicodeEncodeError'ом на Windows-раннере: отчёт печатается по-русски, консоль раннера — cp1252. Тот же класс дефекта, что и в verification-скрипте, и то же лекарство — force_utf8_output до первого вывода. Проверено прогоном под PYTHONIOENCODING=cp1252. Co-Authored-By: Claude Opus 5 --- scripts/release_gate.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/release_gate.py b/scripts/release_gate.py index 7530f31..11e2814 100644 --- a/scripts/release_gate.py +++ b/scripts/release_gate.py @@ -23,9 +23,14 @@ ROOT = Path(__file__).resolve().parent.parent if str(ROOT / "src") not in sys.path: sys.path.insert(0, str(ROOT / "src")) +from antigravity_provider.console_encoding import force_utf8_output from antigravity_provider.version import __version__, get_version from antigravity_provider import paths +# Отчёт ворот печатается по-русски, а консоль Windows-раннера — cp1252. +# Ставится до первого вывода: иначе падает вывод, а не проверки. +force_utf8_output() + def check_version_consistency() -> tuple[bool, str]: ver = get_version() -- 2.45.2 From 7fb8c6a6c567f6eadf24666a31f3caa8edf85a4d Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:29:22 +0700 Subject: [PATCH 05/12] =?UTF-8?q?feat(ci):=20=D0=BC=D0=B0=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D1=86=D0=B0=20Windows=20+=20Linux;=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D0=BF=D0=B0=D0=BA=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BE=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=87=D0=B5=D0=BD=D0=B0=20=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=D0=B3=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 после зелёного main. 1. CI-матрица. Обе джобы стояли на windows-latest, и это дорого обошлось: инвариант A37 не держался на Windows, а четыре теста молча предполагали Linux. Прогон на одной системе не показывал ни того, ни другого. Проект работает на Linux и активно получает Linux-правки — теперь обе системы проверяются одинаковым набором. 2. Zip-slip из аудита НЕ ВОСПРОИЗВОДИТСЯ — измерено, а не принято на веру. Архив с "../", с абсолютным путём и с записью-ссылкой распакован через zipfile.extractall: ничего за пределы каталога не вышло, абсолютный путь стал относительным, "../" схлопнулись, а запись-ссылка легла обычным файлом. CPython санирует пути сам. Но это свойство реализации, а не обещание формата, и распаковка идёт в корень установки. Граница сделана собственным инвариантом: каждая запись проверяется до записи на диск, отклоняются абсолютные пути, выход через "..", ссылки и записи не-файлового типа. Инвариант закреплён тестом, а не оставлен на усмотрение стандартной библиотеки. Тесты: 776 -> 777 passed, 2 skipped, 4 deselected. ruff check . чисто. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 23 ++++++-- .../updater/update_manager.py | 49 +++++++++++++++- tests/test_a59_visible_update.py | 56 +++++++++++++++++++ 3 files changed, 121 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8ca643..cdf3fbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,11 +6,22 @@ on: pull_request: branches: [ main ] +# Матрица из двух систем. +# +# Обе джобы стояли на windows-latest, и это дорого обошлось: инвариант A37 не +# держался на Windows, а тесты установки и остановки процессов молча +# предполагали Linux. Прогон на одной системе не показывал ни того, ни другого. +# Проект работает на Linux и активно получает Linux-правки, поэтому обе системы +# проверяются одинаковым набором. jobs: test: - name: Clean Windows Runner Test - runs-on: windows-latest + name: Clean Runner Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] steps: - name: Checkout repository @@ -40,9 +51,13 @@ jobs: python scripts/release_gate.py headless: - name: Headless Run (no GUI dependencies) - runs-on: windows-latest + name: Headless Run (${{ matrix.os }}, no GUI dependencies) + runs-on: ${{ matrix.os }} timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/src/antigravity_provider/updater/update_manager.py b/src/antigravity_provider/updater/update_manager.py index fd962b4..e3933ba 100644 --- a/src/antigravity_provider/updater/update_manager.py +++ b/src/antigravity_provider/updater/update_manager.py @@ -19,6 +19,7 @@ import logging import os import re import shutil +import zipfile import subprocess import sys import threading @@ -351,6 +352,43 @@ def acknowledge_last_applied_update() -> None: logger.debug("Failed acknowledging last_applied_update: %s", exc) +def _extract_within(zf: "zipfile.ZipFile", dest: Path) -> None: + """Распаковать архив, не выпуская ни одной записи за пределы dest. + + Отклоняются: абсолютные пути, выход через "..", символические ссылки и + любые записи, не являющиеся обычным файлом или каталогом. + """ + import stat as _stat + + root = dest.resolve() + for info in zf.infolist(): + # Биты типа файла проставлены не всегда: архиватор мог записать только + # права доступа. Судим лишь тогда, когда тип действительно указан, — + # иначе обычный файл с правами 0o600 выглядел бы записью чужого типа. + file_type = _stat.S_IFMT(info.external_attr >> 16) + if file_type and file_type not in (_stat.S_IFREG, _stat.S_IFDIR): + raise ValueError( + f"Пакет обновления содержит запись недопустимого типа: {info.filename!r}" + ) + + name = info.filename.replace("\\", "/") + if name.startswith("/") or re.match(r"^[A-Za-z]:", name): + raise ValueError(f"Пакет обновления содержит абсолютный путь: {info.filename!r}") + + target = (root / name).resolve() + if target != root and root not in target.parents: + raise ValueError( + f"Пакет обновления пытается записать за пределы каталога установки: {info.filename!r}" + ) + + if info.is_dir(): + target.mkdir(parents=True, exist_ok=True) + continue + target.parent.mkdir(parents=True, exist_ok=True) + with zf.open(info, "r") as source, open(target, "wb") as out: + shutil.copyfileobj(source, out) + + def stop_running_hub(timeout_sec: float = 10.0) -> bool: """Останавливает только процессы хаба текущего пользователя, исключая текущий PID.""" current_pid = os.getpid() @@ -1212,8 +1250,6 @@ class UpdateManager: def apply_update_sync(self, package_zip: Path, target_dir: Optional[Path] = None) -> Tuple[bool, str]: """Apply update package with automatic backup and rollback on failure.""" - import zipfile - dest = target_dir or paths.get_repo_root() backup = self.backup_dir backup.mkdir(parents=True, exist_ok=True) @@ -1229,8 +1265,15 @@ class UpdateManager: shutil.copytree(src_item, dst_item) # 2. Extract update package into dest + # + # Каждая запись проверяется до записи на диск. Измерено, что + # extractall в CPython уже отбрасывает "..", ведущие разделители и + # буквы дисков, а запись-ссылку кладёт обычным файлом: побега из + # каталога добиться не удалось. Но это свойство реализации, а не + # обещание формата — а распаковка идёт в корень установки. Проверка + # делает границу собственным инвариантом, который виден в тестах. with zipfile.ZipFile(package_zip, "r") as zf: - zf.extractall(dest) + _extract_within(zf, dest) # 3. Verify syntax and integrity of updated python files in src/ import py_compile diff --git a/tests/test_a59_visible_update.py b/tests/test_a59_visible_update.py index bd08e7d..b23c29e 100644 --- a/tests/test_a59_visible_update.py +++ b/tests/test_a59_visible_update.py @@ -556,3 +556,59 @@ def test_p0_2_app_js_handles_refused_cancel(): assert "setInterval(pollUpdateProgress" in tail, ( "после отклонённой отмены опрос хода загрузки должен возобновляться" ) + + +# ── HUB-1: распаковка обновления не выпускает записи за пределы каталога ── + + +@pytest.mark.unit +def test_update_package_cannot_write_outside_target(tmp_path): + """Ни одна запись архива не должна оказаться вне каталога установки. + + Измерено на CPython: extractall сам отбрасывает "..", ведущие разделители + и буквы дисков, а запись-ссылку кладёт обычным файлом — побега добиться не + удалось, вопреки формулировке аудита. Но это свойство реализации, а не + обещание формата, и распаковка идёт в корень установки. Тест закрепляет + границу как собственный инвариант. + """ + import stat as _stat + import zipfile as _zipfile + from antigravity_provider.updater.update_manager import _extract_within + + dest = tmp_path / "dest" + dest.mkdir() + outside = tmp_path / "outside.txt" + + hostile = [ + ("выход через ..", "../outside.txt"), + ("абсолютный путь", "/etc/passwd"), + ("путь с буквой диска", "C:/Windows/x.txt"), + ] + for index, (label, arcname) in enumerate(hostile): + archive = tmp_path / f"hostile_{index}.zip" + with _zipfile.ZipFile(archive, "w") as zf: + zf.writestr(arcname, "побег") + with _zipfile.ZipFile(archive) as zf: + with pytest.raises(ValueError): + _extract_within(zf, dest) + assert not outside.exists(), f"{label}: запись оказалась вне каталога установки" + + # Символическая ссылка — тоже отказ, а не молчаливая распаковка файлом. + link_zip = tmp_path / "link.zip" + with _zipfile.ZipFile(link_zip, "w") as zf: + info = _zipfile.ZipInfo("link") + info.external_attr = (_stat.S_IFLNK | 0o777) << 16 + zf.writestr(info, "/etc/passwd") + with _zipfile.ZipFile(link_zip) as zf: + with pytest.raises(ValueError): + _extract_within(zf, dest) + + # Обычный пакет распаковывается как прежде. + good = tmp_path / "good.zip" + with _zipfile.ZipFile(good, "w") as zf: + zf.writestr("src/module.py", "x = 1\n") + zf.writestr("assets/logo.txt", "logo") + with _zipfile.ZipFile(good) as zf: + _extract_within(zf, dest) + assert (dest / "src" / "module.py").read_text(encoding="utf-8") == "x = 1\n" + assert (dest / "assets" / "logo.txt").is_file() -- 2.45.2 From 1fe4549b22a8206001fb33973db57078136ce871 Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:54:35 +0700 Subject: [PATCH 06/12] =?UTF-8?q?fix(tests):=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D0=B0=20=D1=83=D1=81=D1=82=D0=B0=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D1=88=D0=B5=D0=B3=D0=BE=20refresh=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8=D1=82=20=D0=BE=D1=82=20?= =?UTF-8?q?=D1=84=D0=BE=D0=BD=D0=BE=D0=B2=D1=8B=D1=85=20=D0=BF=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Джоб на ubuntu упал с «assert 32 == 31» в test_seq_token_prevents_stale_refresh_clobber. Тест сравнивал поколения до и после устаревшего вызова, а HubStateStore — процессный синглтон: фоновый сборщик квот, оставшийся от другого теста, успевает поднять generation между двумя вызовами. В логе прогона рядом видно как раз такую фоновую попытку. Падение случайное и зависит от порядка тестов: headless-джоб гоняет pytest без фиксированного порядка. Тем же объясняется разброс 738/739 в базовом прогоне до начала работы. Проверяется теперь инвариант, а не равенство: устаревший ответ отбрасывается ровно один раз, и состояние не откатывается назад. Пять полных прогонов со случайным порядком — 777 passed. Co-Authored-By: Claude Opus 5 --- .../test_state_layer_and_event_driven_quota.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/test_state_layer_and_event_driven_quota.py b/tests/test_state_layer_and_event_driven_quota.py index 4238dcf..460a931 100644 --- a/tests/test_state_layer_and_event_driven_quota.py +++ b/tests/test_state_layer_and_event_driven_quota.py @@ -72,15 +72,27 @@ def test_seq_token_prevents_stale_refresh_clobber(): seq_fresh = store.next_seq() snap_fresh = store.refresh(force_scan=False, seq=seq_fresh) gen_fresh = snap_fresh.generation + skipped_before = store.refresh_skipped_total # Simulate a delayed/stale response from an earlier seq counter seq_stale = seq_fresh - 1 snap_after_stale = store.refresh(force_scan=False, seq=seq_stale) - # Stale response must be rejected, retaining the fresh generation - assert snap_after_stale.generation == gen_fresh + # Устаревший ответ должен быть отброшен. + # + # Проверяется именно отбрасывание, а не равенство поколений. HubStateStore — + # процессный синглтон, и фоновый сборщик квот, оставшийся от другого теста, + # успевает поднять generation между двумя вызовами. Прежнее + # `generation == gen_fresh` падало на этом с «assert 32 == 31» — примерно раз + # на десяток прогонов, только при случайном порядке тестов. Инвариант же + # другой: устаревший ответ отбрасывается, состояние назад не откатывается. + assert store.refresh_skipped_total == skipped_before + 1, ( + "устаревший ответ должен быть отброшен ровно один раз" + ) + assert snap_after_stale.generation >= gen_fresh, ( + "состояние откатилось назад: устаревший ответ затёр более свежее" + ) assert snap_after_stale.seq != seq_stale - assert store.refresh_skipped_total >= 1 @pytest.mark.unit -- 2.45.2 From 1b1143feeb2757a435f6ff7225c3c4ba96d27930 Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:01:33 +0700 Subject: [PATCH 07/12] =?UTF-8?q?docs(agents):=20=D0=BE=D1=82=D1=87=D1=91?= =?UTF-8?q?=D1=82=20HUB-1=20=E2=80=94=20=D0=B7=D0=B5=D0=BB=D1=91=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20main=20=D0=B8=20P0=20=D0=B0=D1=83=D0=B4=D0=B8?= =?UTF-8?q?=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- .../2026-09-02-HUB1-audit-p0-green-main.md | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 agents/reports/2026-09-02-HUB1-audit-p0-green-main.md diff --git a/agents/reports/2026-09-02-HUB1-audit-p0-green-main.md b/agents/reports/2026-09-02-HUB1-audit-p0-green-main.md new file mode 100644 index 0000000..494c396 --- /dev/null +++ b/agents/reports/2026-09-02-HUB1-audit-p0-green-main.md @@ -0,0 +1,208 @@ +# Отчёт HUB-1: зелёный main и P0 из аудита + +## Сдача + +| | | +|---|---| +| Ветка | `hub/audit-p0-green-main` | +| `START_HEAD` | `93da1b22fd2e0385f46b71a1e220fa1e4e716545` | +| `FINAL_HEAD` | `9c25a64df8cefa91e03df3224dbec085f6391b96` | +| `origin/main` на момент сдачи | `93da1b2` (не двигался) | +| PR | https://github.com/ochenstarik-ui/hermes-hub/pull/2 | +| Зелёный прогон CI | https://github.com/ochenstarik-ui/hermes-hub/actions/runs/33670116994 | +| `git status` | чисто (вне репозитория лежит посторонний `gyoza_shorts.mp4`, не мой и не тронут) | + +### Зелёный CI — все четыре джоба + +| Джоб | Итог | Тесты | +|---|---|---| +| Clean Runner Test (windows-latest) | **pass** | 774 passed, 3 skipped, 4 deselected | +| Clean Runner Test (ubuntu-latest) | **pass** | 773 passed, 4 skipped, 4 deselected | +| Headless Run (windows-latest) | **pass** | 774 passed, 3 skipped, 4 deselected | +| Headless Run (ubuntu-latest) | **pass** | 773 passed, 4 skipped, 4 deselected | + +`ruff check .` — чисто. Release Gate — PASSED на обеих системах. + +Локально (Linux): **777 passed, 2 skipped, 4 deselected**. База до работы — +739 passed, 2 skipped. Число тестов выросло на 38, ни один не удалён. + +--- + +## P0-1. Зелёный main + +### Обе причины из задания подтвердились — и обе оказались шире описания + +**1. Инвариант A37 не держался на Windows.** Причина именно та, что +предполагалась. Воспроизведено локально на окружении Windows (нет переменной +`HOME`): `os.path.expandvars("$HOME/.hermes")` оставляет строку как есть, путь +перестаёт быть абсолютным, склеивается с каталогом проекта и оказывается +«внутри разрешённого корня» — команда проходит. + +Заодно нашлась **зеркальная дыра, в задании не названная**: на Linux так же +проходили `rm -rf %USERPROFILE%\.hermes` и `del /f /q C:\Windows\System32`. +`Path("C:/Windows").resolve()` на Linux приписывает пути текущий каталог, и +удаление системного каталога Windows выглядело работой внутри проекта. + +Разбор пути сведён в один конвейер, как и требовало задание: классификация +диалекта shell **по самой команде, а не по системе-хозяину** → раскрытие +распознанных переменных, с разрешением `HOME`/`USERPROFILE` в домашний каталог +даже когда их нет в окружении → нормализация разделителей → канонизация → +сравнение с защищёнными корнями. Каждый несостоявшийся шаг **закрывает +проход**: непроверяемый путь не считается разрешённым. Через тот же конвейер +пропущены `validate_path`, `is_forbidden_path`, `is_inside_allowed_root`. + +Доказательство: новые тесты воспроизводят окружение обеих систем на любой из +них. На прежнем guard они падают — **6 failed**, ровно на дефекте из CI и на +зеркальных случаях; на новом проходят. `test_a37_isolation_guards` зелёный на +Windows-раннере. + +**2. UTF-8 ронял verification-скрипт.** Воспроизведено точно: строка 63, тот же +`UnicodeEncodeError`. Общий помощник `console_encoding.force_utf8_output` +ставит UTF-8 на потоки и оставляет запасной путь, если поток перекодировать +нельзя. Той же реализацией заменён самодельный блок в `cli_commands`. +Скрипт проходит **10/10** под `PYTHONIOENCODING=cp1252` и под `ascii`. + +### Причин красного CI было не две, а семь + +Это главное расхождение с заданием. Ревьюер видел две; живой прогон на +Windows после их устранения показал ещё пять. Четыре из них — **не дефекты +продукта, а допущения тестов, зашитые под Linux**: + +1. `test_a41` читал вывод скрипта в кодировке системы. Скрипт стал писать + UTF-8, а родитель на Windows читал трубу как cp1252 и разваливался на + `UnicodeDecodeError`, оставляя `proc.stdout` равным `None`. Кодировка + задана явно с обеих сторон трубы. +2. `test_p0_3_stop_running_hub` знал только про ветку Linux (`os.kill` по + списку от `pgrep`). На Windows процессы останавливает `taskkill` по списку + от `wmic`. Инвариант один — «чужой процесс останавливается, свой PID не + трогаем» — теперь проверяется на обеих ветках. +3-4. Оба теста установки подсовывали bash-скрипт `hermes-hub-setup.sh`; на + Windows выбирается `HermesHubSetup.exe`, и установка честно отвечала «в + релизе не найден подходящий файл обновления». Установщик берётся под ту + систему, на которой идёт прогон. Проверка сообщения смотрит, назван ли код + возврата, а не на склонение: ветки формулируют «код 3» и «кодом 3». + +Пятая — моя собственная: добавленный русский вывод Release Gate уронил шаг +на cp1252. Тот же класс дефекта, то же лекарство. + +Шестая — **флейк, из-за которого main краснел случайно**: базовый прогон до +начала работы дал то 738, то 739. Причина найдена по падению ubuntu-джоба: +`test_seq_token_prevents_stale_refresh_clobber` сравнивал поколения до и после +устаревшего вызова, а `HubStateStore` — процессный синглтон, и фоновый сборщик +квот от другого теста успевает поднять `generation` между вызовами. Теперь +проверяется инвариант (устаревший ответ отброшен ровно один раз, состояние +назад не откатывается), а не равенство. Пять полных прогонов со случайным +порядком — 777 passed. + +--- + +## P0-2. Остальные P0 аудита — каждый подтверждён исполнением + +### 1. Release Gate заявлял проверку хеша, которой не было — **подтвердилось** + +Хуже, чем в аудите. `hashlib` в `scripts/release_gate.py` **не вызывался ни +разу**: скачивались байты 0-10 через заголовок `Range`, и этого хватало, чтобы +напечатать `PACKAGE_HASH_VERIFIED=True`. «Проверенным ассетом» при этом +оказывался первый в списке — `checksums.txt`, а не пакет. + +### 2. Publication gate fail-open — **подтвердилось**, во всех трёх условиях + +Измерено прогоном самой функции: + +| Условие | Прежний вердикт | +|---|---| +| Полный обрыв сети | **PASS** | +| Манифест 404 (релиза нет) | **PASS** | +| Пакет 404 (ассет не загружен) | **PASS** | + +Ворота пропускали релиз при любом исходе, включая полное отсутствие релиза. + +Разделено, как требовало задание: офлайновая часть (версии, тесты, updater, +статика, секреты, список разрешённых адресов) блокирует всегда; Publication +Gate проверяет, что релиз есть, ассеты есть, пакет скачан **целиком** и +SHA-256 сошёлся с опубликованным `checksums.txt`. Блокирует в режиме +публикации (`--publication` или `HERMES_RELEASE_PUBLICATION_GATE=1`); в +обычном прогоне CI, где релиза для ветки нет и быть не должно, результат +сообщается как есть и не блокирует. Неизмеренное называется причиной, а не +выдаётся за проверенное. + +Проверено на живом релизе `v0.1.3-b1`: два пакета скачаны целиком, хеши +сошлись. Проверено на отказах: обрыв сети и 404 теперь **FAIL**. + +### 3. localhost `/api/action` без CSRF — **подтвердилось** + +CORS уже закрыт правкой ревьюера, но CORS мешает **прочитать** ответ, а не +**отправить** запрос. Измерено на конфигурации по умолчанию +(`web_api_host=127.0.0.1`): POST с `Content-Type: text/plain` уходит +кросс-сайтом без предварительного запроса (простой запрос по правилам CORS), а +`request.json()` разбирает тело независимо от `Content-Type`. Запрос с +`Origin: https://evil.example.com` и без токена доходил до исполнителя +действий — отвечало уже само действие. Среди доступных действий +`clear_accounts`, `delete_credentials`, `set_main`. + +Проверяется `Sec-Fetch-Site`, при его отсутствии — `Origin` против адреса +запроса. Замер после правки: + +| Запрос | Итог | +|---|---| +| чужой сайт, `Sec-Fetch-Site: cross-site` | **403** | +| чужой сайт, старый браузер (только `Origin`) | **403** | +| собственный интерфейс | 200 | +| адресная строка / расширение | 200 | +| не-браузерный клиент (curl, CLI) | 200 | + +Защита распространена на все пять небезопасных методов, не только на +`/api/action`. + +--- + +## P1 + +1. **Zip-slip — НЕ ВОСПРОИЗВОДИТСЯ.** Это единственное расхождение с аудитом + по существу, и оно в пользу продукта. Архив с `../`, с абсолютным путём и с + записью-ссылкой распакован через `zipfile.extractall`: ничего за пределы + каталога не вышло, абсолютный путь стал относительным, `../` схлопнулись, а + запись-ссылка легла обычным файлом. CPython санирует пути сам. + + Но это свойство реализации, а не обещание формата, и распаковка идёт в + корень установки. Граница сделана собственным инвариантом: каждая запись + проверяется до записи на диск, отклоняются абсолютные пути, выход через + `..`, ссылки и записи не-файлового типа. Инвариант закреплён тестом, а не + оставлен на усмотрение стандартной библиотеки. + +2. **pricing fallback** — исправлено: `safe_load` вместо `safe_dump`. `dump` + сериализовал текст обратно в строку, `isinstance(data, dict)` не + выполнялось никогда, таблица цен не загружалась ни разу, а `except` это + глушил. + +3. **CI-матрица Windows + Linux** — сделано, обе джобы. Именно отсутствие + Linux-джоба и позволяло четырём платформенным допущениям прятаться; на + первом же прогоне матрицы Linux-джоб поймал флейк, который Windows не + показывал. + +4. Прочее из аудита (failover error policy, `uv sync --frozen`, лишний `web` + extra, secret-scan шире) — не трогал, по заданию это отдельные задания. + +--- + +## Ограничения задания — соблюдены + +- Правки ревьюера из `main` не откатывались; в `main` напрямую не пушил. +- Фронтенд не трогал: npm, сборки и фреймворков не добавлено. +- Проверка SHA-256 **усилена**, а не ослаблена; список разрешённых адресов не + тронут. +- Учётные данные и `~/.hermes/agy_profiles/` не тронуты. +- Версия `0.1.3` не понижена. +- Неизмеренное названо причиной: Publication Gate вне режима публикации + печатает «НЕ БЛОКИРУЕТ» с причиной и не заявляет `PACKAGE_HASH_VERIFIED`. + +## Что стоит решить ревьюеру + +- Джобы переименованы (`Clean Windows Runner Test` → `Clean Runner Test + (windows-latest)`). Защиты ветки на `main` сейчас нет, так что ничего не + сломалось; если её будут включать — имена проверок брать новые. +- Publication Gate по умолчанию не блокирует. Это осознанный выбор: иначе + каждый PR краснел бы за отсутствие релиза для ветки. Перед публикацией + релиза его нужно запускать явно — `python scripts/release_gate.py + --publication`. Имеет смысл добавить этот вызов в `release.yml` отдельным + заданием. -- 2.45.2 From 922c437689187cb944b2ad0809af9234e57353af Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:06:25 +0700 Subject: [PATCH 08/12] =?UTF-8?q?docs(agents):=20=D0=BE=D1=82=D1=87=D1=91?= =?UTF-8?q?=D1=82=20HUB-1=20=E2=80=94=20=D1=82=D0=BE=D1=87=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20FINAL=5FHEAD=20=D0=B8=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- agents/reports/2026-09-02-HUB1-audit-p0-green-main.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/agents/reports/2026-09-02-HUB1-audit-p0-green-main.md b/agents/reports/2026-09-02-HUB1-audit-p0-green-main.md index 494c396..53cb3e3 100644 --- a/agents/reports/2026-09-02-HUB1-audit-p0-green-main.md +++ b/agents/reports/2026-09-02-HUB1-audit-p0-green-main.md @@ -6,10 +6,10 @@ |---|---| | Ветка | `hub/audit-p0-green-main` | | `START_HEAD` | `93da1b22fd2e0385f46b71a1e220fa1e4e716545` | -| `FINAL_HEAD` | `9c25a64df8cefa91e03df3224dbec085f6391b96` | +| `FINAL_HEAD` | `1982342f42283a6c2be91ceed8d8aa2f6e8a2ace` | | `origin/main` на момент сдачи | `93da1b2` (не двигался) | | PR | https://github.com/ochenstarik-ui/hermes-hub/pull/2 | -| Зелёный прогон CI | https://github.com/ochenstarik-ui/hermes-hub/actions/runs/33670116994 | +| Зелёный прогон CI на `FINAL_HEAD` | https://github.com/ochenstarik-ui/hermes-hub/actions/runs/33670815897 | | `git status` | чисто (вне репозитория лежит посторонний `gyoza_shorts.mp4`, не мой и не тронут) | ### Зелёный CI — все четыре джоба -- 2.45.2 From ec656a0e085d2b2f56e122903fc89e0e6f6e3d1e Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:44:14 +0700 Subject: [PATCH 09/12] =?UTF-8?q?fix(release):=20=D0=BA=D0=BE=D0=BD=D0=B2?= =?UTF-8?q?=D0=B5=D0=B9=D0=B5=D1=80=20=D0=BD=D0=B5=20=D0=BF=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D0=BA=D1=83=D0=B5=D1=82=20=D1=80=D0=B5=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7,=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D1=8B=D0=BC=20=D0=BD?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=B7=D1=8F=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Найдено сверх задания и сверх аудита. Каждый прогон Release Pipeline завершался ошибкой — все пять последних, включая тег текущего релиза v0.1.3-b1. Причина та же, что у красного CI: шаг Release Gate падал на test_a37_isolation_guards и test_a41_clean_install. До публикации не доходил ни один прогон, релизы выкладывались мимо конвейера. Отсюда ловушка. release.yml собирает hermes-hub-<версия>.zip и update_manifest.json, а update_manager ищет строго HermesHubSetup.exe или hermes-hub-setup.sh. Настоящие релизы содержат установщики и checksums.txt, то есть собраны не этим конвейером. Пока тесты были красными, конвейер падал и ничего не публиковал; как только они позеленели, случайная защита исчезла: первый же тег опубликовал бы "latest" без установщиков, и любое обновление отвечало бы "В релизе не найден подходящий файл обновления для текущей платформы". Ловушка закрыта до публикации: release_gate.py --assets dist проверяет, что собранный набор содержит установщик и checksums.txt, и падает с названной причиной и подсказкой про installer/build_installer.*. После публикации добавлен шаг release_gate.py --publication-only — строгий режим, ради которого ворота и разделялись. Сборку установщиков в release.yml не переписывал: проверяется только настоящей публикацией по тегу, это решение владельца. Конвейер по-прежнему не доходит до публикации, но падает теперь с честной причиной вместо чужой. Отчёт перенесён в agents/done/ по конвенции репозитория. Тесты: 777 -> 778 passed, 2 skipped, 4 deselected. ruff check . чисто. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 24 ++++++++ .../2026-09-02-HUB1-audit-p0-green-main.md | 52 ++++++++++++++-- scripts/release_gate.py | 60 ++++++++++++++++++- tests/test_p0_release_gate.py | 41 +++++++++++++ 4 files changed, 170 insertions(+), 7 deletions(-) rename agents/{reports => done}/2026-09-02-HUB1-audit-p0-green-main.md (79%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4b00ec..99b22e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,6 +57,21 @@ jobs: $manifest | ConvertTo-Json -Depth 5 | Out-File -FilePath "$distDir/update_manifest.json" -Encoding utf8 Write-Host "Generated update_manifest.json with SHA256: $hash" + # Набор проверяется ДО публикации. + # + # update_manager ищет в релизе строго HermesHubSetup.exe или + # hermes-hub-setup.sh, а шаг выше собирает только zip и манифест. Такой + # релиз становится "latest", и любая попытка обновиться отвечает «в + # релизе не найден подходящий файл обновления для текущей платформы». + # + # Раньше это не проявлялось лишь потому, что весь конвейер падал на шаге + # Release Gate — на тех же двух дефектах, что и CI; ни один его прогон не + # доходил до публикации, а релизы выкладывались мимо него. Как только + # тесты позеленели, случайная защита исчезла. + - name: Built assets must be installable by the updater + run: | + python scripts/release_gate.py --assets dist + - name: Publish GitHub Release uses: softprops/action-gh-release@v2 with: @@ -68,3 +83,12 @@ jobs: prerelease: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Ворота публикации: проверяют опубликованный релиз, а не сборку. + # Релиз есть, ассеты есть, пакет скачан целиком, SHA-256 сошёлся с + # опубликованным checksums.txt. Здесь они блокируют: раньше эта проверка + # возвращала PASS при обрыве сети, при 404 на манифест и при 404 на + # пакет, то есть пропускала релиз при любом исходе. + - name: Publication Gate (published release must be verifiable) + run: | + python scripts/release_gate.py --publication-only diff --git a/agents/reports/2026-09-02-HUB1-audit-p0-green-main.md b/agents/done/2026-09-02-HUB1-audit-p0-green-main.md similarity index 79% rename from agents/reports/2026-09-02-HUB1-audit-p0-green-main.md rename to agents/done/2026-09-02-HUB1-audit-p0-green-main.md index 53cb3e3..f257aff 100644 --- a/agents/reports/2026-09-02-HUB1-audit-p0-green-main.md +++ b/agents/done/2026-09-02-HUB1-audit-p0-green-main.md @@ -23,8 +23,8 @@ `ruff check .` — чисто. Release Gate — PASSED на обеих системах. -Локально (Linux): **777 passed, 2 skipped, 4 deselected**. База до работы — -739 passed, 2 skipped. Число тестов выросло на 38, ни один не удалён. +Локально (Linux): **778 passed, 2 skipped, 4 deselected**. База до работы — +739 passed, 2 skipped. Число тестов выросло на 39, ни один не удалён. --- @@ -196,13 +196,53 @@ CORS уже закрыт правкой ревьюера, но CORS мешает - Неизмеренное названо причиной: Publication Gate вне режима публикации печатает «НЕ БЛОКИРУЕТ» с причиной и не заявляет `PACKAGE_HASH_VERIFIED`. +--- + +## Найдено сверх задания: релизный конвейер был мёртв, и мой же фикс снимал с него защиту + +Это самое важное из того, что не значилось ни в задании, ни в аудите. + +**Каждый прогон `Release Pipeline` завершался ошибкой** — все пять последних, +включая тег текущего релиза `v0.1.3-b1`. Причина ровно та же, что у красного +CI: шаг `Run Release Gate Check` падал на `test_a37_isolation_guards` и +`test_a41_clean_install`. До публикации не доходил ни один прогон, а релизы +выкладывались мимо конвейера. + +**Отсюда ловушка.** `release.yml` собирает `hermes-hub-<версия>.zip` и +`update_manifest.json`, а `update_manager` ищет в релизе строго +`HermesHubSetup.exe` или `hermes-hub-setup.sh`. Настоящие релизы содержат +`HermesHubSetup.exe`, `hermes-hub-setup.sh` и `checksums.txt` — то есть +собраны не этим конвейером. Пока тесты были красными, конвейер падал и ничего +не публиковал; **как только я их починил, случайная защита исчезла**: первый +же тег привёл бы к публикации «latest» без установщиков, и любая попытка +обновиться отвечала бы «В релизе не найден подходящий файл обновления для +текущей платформы». + +Ловушка закрыта явно, до публикации: шаг `Built assets must be installable by +the updater` (`release_gate.py --assets dist`) проверяет, что собранный набор +содержит установщик и `checksums.txt`, и падает с названной причиной и +подсказкой про `installer/build_installer.ps1` и +`installer/build_installer_linux.sh`. После публикации добавлен шаг +`Publication Gate` (`release_gate.py --publication-only`) — тот самый строгий +режим, ради которого ворота и разделялись. + +Чего я **не** делал: не переписывал сборку установщиков в `release.yml`. +Проверить это можно только выкладыванием настоящего релиза по тегу, а это +решение владельца, не исполнителя. Конвейер по-прежнему не доходит до +публикации — но теперь падает с честной причиной вместо чужой. + ## Что стоит решить ревьюеру - Джобы переименованы (`Clean Windows Runner Test` → `Clean Runner Test (windows-latest)`). Защиты ветки на `main` сейчас нет, так что ничего не сломалось; если её будут включать — имена проверок брать новые. - Publication Gate по умолчанию не блокирует. Это осознанный выбор: иначе - каждый PR краснел бы за отсутствие релиза для ветки. Перед публикацией - релиза его нужно запускать явно — `python scripts/release_gate.py - --publication`. Имеет смысл добавить этот вызов в `release.yml` отдельным - заданием. + каждый PR краснел бы за отсутствие релиза для ветки. В `release.yml` он уже + встроен и блокирует (`--publication-only`, после публикации). Вручную: + `python scripts/release_gate.py --publication`. +- **Главное решение — сборка установщиков в `release.yml`.** Конвейер собирает + zip, которым обновиться нельзя. Скрипты `installer/build_installer.ps1` и + `installer/build_installer_linux.sh` в репозитории есть, но Linux-установщик + требует Linux-раннера, то есть релизной джобе нужна матрица. Работа + небольшая, но проверяется только настоящей публикацией по тегу — поэтому + оставлена за владельцем. diff --git a/scripts/release_gate.py b/scripts/release_gate.py index 11e2814..2198931 100644 --- a/scripts/release_gate.py +++ b/scripts/release_gate.py @@ -377,6 +377,45 @@ def check_publication_gate() -> tuple[bool, str]: ) +def check_publishable_assets(dist_dir: Path) -> tuple[bool, str]: + """Собранный набор ассетов действительно устанавливается обновлением. + + Проверяется до публикации. Причина: update_manager ищет в релизе строго + HermesHubSetup.exe или hermes-hub-setup.sh/install-linux.sh, а release.yml + собирает hermes-hub-<версия>.zip и update_manifest.json. Такой релиз + становится "latest", и на любой попытке обновиться владелец получает + "В релизе не найден подходящий файл обновления для текущей платформы". + + Раньше это не проявлялось лишь потому, что весь релизный конвейер падал + на тех же двух дефектах, что и CI: каждый его прогон завершался ошибкой, а + релизы публиковались мимо него. Как только тесты позеленели, случайная + защита исчезла — поэтому набор проверяется явно. + """ + if not dist_dir.is_dir(): + return False, f"Каталог сборки не найден: {dist_dir}" + + present = {item.name for item in dist_dir.iterdir() if item.is_file()} + installers = sorted(present & set(PACKAGE_ASSET_NAMES)) + problems = [] + if not installers: + problems.append( + f"нет ни одного установщика {list(PACKAGE_ASSET_NAMES)} — " + f"обновление такой релиз поставить не сможет" + ) + if CHECKSUMS_ASSET_NAME not in present: + problems.append(f"нет {CHECKSUMS_ASSET_NAME} — сверять хеш пакета будет не с чем") + + if problems: + return False, ( + f"Набор ассетов в {dist_dir} непригоден для публикации: " + + "; ".join(problems) + + f". Собрано: {sorted(present)}. Установщики собираются скриптами " + f"installer/build_installer.ps1 и installer/build_installer_linux.sh" + ) + + return True, f"Набор ассетов пригоден для публикации: {installers} + {CHECKSUMS_ASSET_NAME}" + + def run_release_gate(): print("=" * 70) print(f" Hermes Hub — Release Gate Verification Suite (Target: v{__version__})") @@ -416,5 +455,24 @@ def run_release_gate(): sys.exit(1) +def _run_single(title: str, check) -> None: + """Выполнить одну проверку и завершиться её итогом.""" + print("=" * 70) + print(f" Hermes Hub — {title}") + print("=" * 70) + ok, msg = check() + print(f" {'[OK]' if ok else '[FAIL]'} {msg}") + sys.exit(0 if ok else 1) + + if __name__ == "__main__": - run_release_gate() + if "--assets" in sys.argv: + index = sys.argv.index("--assets") + target = Path(sys.argv[index + 1]) if len(sys.argv) > index + 1 else ROOT / "dist" + _run_single("Publishable Assets Check", lambda: check_publishable_assets(target)) + elif "--publication-only" in sys.argv: + # Запускается ПОСЛЕ публикации: проверяет опубликованный релиз, а не сборку. + os.environ[PUBLICATION_MODE_ENV] = "1" + _run_single("Publication Gate", check_publication_gate) + else: + run_release_gate() diff --git a/tests/test_p0_release_gate.py b/tests/test_p0_release_gate.py index eeef7cf..430501c 100644 --- a/tests/test_p0_release_gate.py +++ b/tests/test_p0_release_gate.py @@ -621,3 +621,44 @@ def test_offline_run_does_not_claim_publication_verified(monkeypatch): assert ok is True, "обычный прогон CI не должен блокироваться отсутствием релиза" assert "НЕ БЛОКИРУЕТ" in msg assert "PACKAGE_HASH_VERIFIED=True" not in msg, "непроверенное не должно объявляться проверенным" + + +@pytest.mark.unit +def test_publishable_assets_check_rejects_uninstallable_release(tmp_path): + """Набор без установщика не должен уходить в публикацию. + + update_manager ищет в релизе HermesHubSetup.exe или hermes-hub-setup.sh, а + release.yml собирает только zip и манифест. Такой релиз становится + «latest», и обновление отвечает «в релизе не найден подходящий файл + обновления для текущей платформы». Раньше это не проявлялось лишь потому, + что весь релизный конвейер падал на шаге Release Gate — на тех же двух + дефектах, что и CI, — и до публикации не доходил ни один его прогон. + """ + release_gate = _load_release_gate() + + as_built_today = tmp_path / "dist_zip_only" + as_built_today.mkdir() + (as_built_today / "hermes-hub-0.1.3.zip").write_bytes(b"zip") + (as_built_today / "update_manifest.json").write_text("{}", encoding="utf-8") + + ok, msg = release_gate.check_publishable_assets(as_built_today) + assert ok is False, "набор без установщика признан пригодным к публикации" + assert "HermesHubSetup.exe" in msg + assert "checksums.txt" in msg + + without_checksums = tmp_path / "dist_no_sums" + without_checksums.mkdir() + (without_checksums / "HermesHubSetup.exe").write_bytes(b"exe") + ok, msg = release_gate.check_publishable_assets(without_checksums) + assert ok is False, "набор без checksums.txt признан пригодным" + assert "checksums.txt" in msg + + as_published_really = tmp_path / "dist_full" + as_published_really.mkdir() + for name in ("HermesHubSetup.exe", "hermes-hub-setup.sh", "checksums.txt"): + (as_published_really / name).write_bytes(b"x") + ok, msg = release_gate.check_publishable_assets(as_published_really) + assert ok is True, msg + + ok, msg = release_gate.check_publishable_assets(tmp_path / "нет-такого") + assert ok is False, "отсутствующий каталог сборки должен быть отказом" -- 2.45.2 From 713441ae390d97109b5ab8c3678a4c72b52dc0e5 Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:50:13 +0700 Subject: [PATCH 10/12] =?UTF-8?q?docs(agents):=20=D0=BE=D1=82=D1=87=D1=91?= =?UTF-8?q?=D1=82=20HUB-1=20=E2=80=94=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BD=D0=B0=20=D0=B8=D1=82=D0=BE=D0=B3=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=20=D0=B8=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=87=D0=B8=D0=B9=20=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=BC=D0=B8=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- .../done/2026-09-02-HUB1-audit-p0-green-main.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/agents/done/2026-09-02-HUB1-audit-p0-green-main.md b/agents/done/2026-09-02-HUB1-audit-p0-green-main.md index f257aff..8521364 100644 --- a/agents/done/2026-09-02-HUB1-audit-p0-green-main.md +++ b/agents/done/2026-09-02-HUB1-audit-p0-green-main.md @@ -6,20 +6,20 @@ |---|---| | Ветка | `hub/audit-p0-green-main` | | `START_HEAD` | `93da1b22fd2e0385f46b71a1e220fa1e4e716545` | -| `FINAL_HEAD` | `1982342f42283a6c2be91ceed8d8aa2f6e8a2ace` | +| Последний рабочий коммит | `f061961600dd2eb3d6901cc154dbf5a82456dd14` | | `origin/main` на момент сдачи | `93da1b2` (не двигался) | | PR | https://github.com/ochenstarik-ui/hermes-hub/pull/2 | -| Зелёный прогон CI на `FINAL_HEAD` | https://github.com/ochenstarik-ui/hermes-hub/actions/runs/33670815897 | +| Зелёный прогон CI | https://github.com/ochenstarik-ui/hermes-hub/actions/runs/33729660525 | | `git status` | чисто (вне репозитория лежит посторонний `gyoza_shorts.mp4`, не мой и не тронут) | ### Зелёный CI — все четыре джоба -| Джоб | Итог | Тесты | -|---|---|---| -| Clean Runner Test (windows-latest) | **pass** | 774 passed, 3 skipped, 4 deselected | -| Clean Runner Test (ubuntu-latest) | **pass** | 773 passed, 4 skipped, 4 deselected | -| Headless Run (windows-latest) | **pass** | 774 passed, 3 skipped, 4 deselected | -| Headless Run (ubuntu-latest) | **pass** | 773 passed, 4 skipped, 4 deselected | +| Джоб | Итог | +|---|---| +| Clean Runner Test (windows-latest) | **pass** | +| Clean Runner Test (ubuntu-latest) | **pass** | +| Headless Run (windows-latest) | **pass** | +| Headless Run (ubuntu-latest) | **pass** | `ruff check .` — чисто. Release Gate — PASSED на обеих системах. -- 2.45.2 From fc4707d9d958cae4e1c190b903a7ce57ec3b9a0e Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:02:43 +0700 Subject: [PATCH 11/12] =?UTF-8?q?fix(linux):=20=D0=B7=D0=BD=D0=B0=D1=87?= =?UTF-8?q?=D0=BE=D0=BA=20.desktop-=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=80=D0=B5=D0=BD=D0=B4=D0=B5=D1=80=D0=B8=D0=BB?= =?UTF-8?q?=D1=81=D1=8F;=20=D1=85=D0=B0=D0=B1=20=D0=BD=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=D0=B7=D1=8F=20=D0=B1=D1=8B=D0=BB=D0=BE=20=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Найдено и проверено живым прогоном на этой машине (реальный GTK-рабочий стол, реальные профили agy) — не по чтению кода. 1. Значок .desktop-записи был .ico. Измерено: GdkPixbuf.Pixbuf.new_from_file на HermesHub.ico падает с "Compressed icons are not supported". .desktop-файл с нерендерящейся иконкой меню приложений и файловый менеджер просто показывают пустым — без ошибки, молча. PNG в тех же ассетах уже был и загружается (проверено: 256x256). Подставлен app_icon_256.png. 2. Хаб нельзя было остановить иначе как из терминала. На Windows сервер стартует из HermesHubWeb.exe, который держит значок в системном трее — оттуда «Exit» останавливает процесс. На Linux сервер остаётся в фоне после закрытия окна браузера (так и задумано — не переустанавливать каждый раз), но ни кнопки в интерфейсе (её нет ни на одной платформе), ни трея, ни пункта меню не было вовсе. launcher/hermes-hub-stop.sh — недостающий эквивалент «Exit из трея»: доступен из меню приложений через собственный .desktop-пункт, использует ту же функцию, что и install/uninstall. Проверено живым прогоном: сервер запущен, остановлен через новый лаунчер, curl после этого получает connection refused. 3. uninstall-linux.sh не останавливал работающий хаб перед удалением файлов — та же причина, что install уже чинил для установки: с --purge-user-data это ещё и rm -rf каталогов, на которые у живого процесса открыты файловые дескрипторы. Измерено: сервер, запущенный в песочнице, оставался в списке процессов после uninstall-linux.sh до этой правки. Добавлена та же остановка, тем же кодом. 4. stop_running_hub была вписана отдельно в install-linux.sh и uninstall-linux.sh — две копии, которые разошлись бы при правке одной незамеченной для другой. Вынесена в installer/lib_stop_running_hub.sh, источается обоими скриптами и новым лаунчером остановки. Всё проверено дважды: прямым запуском install-linux.sh/uninstall-linux.sh в изолированной песочнице (не ~/.hermes) и через собранный dist/hermes-hub-setup.sh — тот самый файл, который уходит в релиз. Тесты: 738 -> 740 passed, 2 skipped, 4 deselected. Новый тест на .ico падает на прежней версии install-linux.sh (проверено git stash) и проходит после фикса. ruff check . чисто. Co-Authored-By: Claude Sonnet 5 --- installer/install-linux.sh | 97 ++++++++++++----------- installer/lib_stop_running_hub.sh | 54 +++++++++++++ installer/uninstall-linux.sh | 36 ++++++++- launcher/hermes-hub-stop.sh | 49 ++++++++++++ tests/test_installer_windows_and_linux.py | 57 +++++++++++++ 5 files changed, 243 insertions(+), 50 deletions(-) create mode 100644 installer/lib_stop_running_hub.sh create mode 100644 launcher/hermes-hub-stop.sh diff --git a/installer/install-linux.sh b/installer/install-linux.sh index 3f9556d..dd89f24 100644 --- a/installer/install-linux.sh +++ b/installer/install-linux.sh @@ -49,51 +49,8 @@ echo "" # крутить старый код в памяти, и владелец видел прежний интерфейс при новом # номере сборки. Три сборки подряд ставились в файлы, но не в работу. echo "[0/6] Остановка работающего Hermes Hub..." - -stop_running_hub() { - local pattern="antigravity_provider.router.web|hermes_hub_web_entry" - local pids - # Только процессы ЭТОГО пользователя и только те, что относятся к хабу. - pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr ' -' ' ')" - - if [ -z "$pids" ]; then - echo " Работающий хаб не найден — останавливать нечего." - return 0 - fi - - echo " Найдены процессы хаба: $pids" - # shellcheck disable=SC2086 - kill $pids 2>/dev/null || true - - local waited=0 - while [ "$waited" -lt 10 ]; do - sleep 1 - waited=$((waited + 1)) - pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr ' -' ' ')" - [ -z "$pids" ] && break - done - - if [ -n "$pids" ]; then - echo " Не завершились за 10 секунд, снимаю принудительно: $pids" - # shellcheck disable=SC2086 - kill -9 $pids 2>/dev/null || true - sleep 1 - pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr ' -' ' ')" - fi - - if [ -n "$pids" ]; then - # Не прерываем установку: файлы обновятся, а владельцу скажем правду. - echo " ⚠ Остались процессы: $pids. Снимите их вручную, иначе будет работать старый код." - return 1 - fi - - echo " Хаб остановлен." - return 0 -} - +# shellcheck source=./lib_stop_running_hub.sh +. "$SCRIPT_DIR/lib_stop_running_hub.sh" stop_running_hub || true echo "" @@ -266,9 +223,35 @@ mkdir -p "$HERMES_HOME/bin" cp "$LAUNCHER_SRC" "$HERMES_HOME/bin/hermes-hub-web" chmod +x "$HERMES_HOME/bin/hermes-hub-web" +# Лаунчер остановки — эквивалент «Exit» из системного трея Windows. +# +# На Windows сервер стартует из HermesHubWeb.exe, который держит значок в +# трее: закрыть его оттуда может сам владелец. На Linux сервер после закрытия +# окна остаётся в фоне без единого способа его остановить — ни кнопки в +# интерфейсе (её нет ни на одной платформе), ни трея, ни пункта меню. Кладём +# lib_stop_running_hub.sh рядом со скриптом остановки: он ищет её сначала +# рядом с собой. +STOP_LAUNCHER_SRC="$REPO_ROOT/launcher/hermes-hub-stop.sh" +if [ ! -f "$STOP_LAUNCHER_SRC" ]; then + STOP_LAUNCHER_SRC="$SCRIPT_DIR/../launcher/hermes-hub-stop.sh" +fi +STOP_LAUNCHER_BIN="$HOME/.local/bin/hermes-hub-stop" +if [ -f "$STOP_LAUNCHER_SRC" ]; then + cp "$STOP_LAUNCHER_SRC" "$STOP_LAUNCHER_BIN" + chmod +x "$STOP_LAUNCHER_BIN" + cp "$SCRIPT_DIR/lib_stop_running_hub.sh" "$HOME/.local/bin/lib_stop_running_hub.sh" +fi + # Create .desktop file +# +# Иконка — PNG, не .ico. Измерено на настоящем GTK-рабочем столе: +# GdkPixbuf.Pixbuf.new_from_file на HermesHub.ico падает с "Compressed icons +# are not supported", а .desktop-файл с несуществующей или неподдерживаемой +# иконкой Nautilus и меню приложений просто показывают пустое место — без +# ошибки, молча. Значок был бы вечно пустым на любом GTK-окружении (GNOME, +# большинство производных). PNG в тех же ассетах уже есть и загружается. DESKTOP_FILE="$HOME/.local/share/applications/hermes-hub-web.desktop" -ICON_PATH="$HERMES_HOME/plugins/antigravity-provider/assets/branding/app/HermesHub.ico" +ICON_PATH="$HERMES_HOME/plugins/antigravity-provider/assets/branding/app/app_icon_256.png" if [ ! -f "$ICON_PATH" ]; then ICON_PATH="utilities-terminal" fi @@ -289,6 +272,28 @@ StartupWMClass=hermes-hub-web EOF chmod +x "$DESKTOP_FILE" + +# Второй пункт меню — «Остановить». Terminal=true: без окна владелец не +# увидит, остановился ли хаб на самом деле, и не заметит «⚠ Остались +# процессы» из lib_stop_running_hub.sh, если что-то пошло не так. +if [ -f "$STOP_LAUNCHER_BIN" ]; then + STOP_DESKTOP_FILE="$HOME/.local/share/applications/hermes-hub-stop.desktop" + cat < "$STOP_DESKTOP_FILE" +[Desktop Entry] +Version=1.0 +Type=Application +Name=Stop Hermes Hub +GenericName=Stop the Hermes Hub background server +Comment=Останавливает фоновый сервер Hermes Hub +Exec=$STOP_LAUNCHER_BIN +Icon=$ICON_PATH +Terminal=true +Categories=Development;Utility; +StartupNotify=false +EOF + chmod +x "$STOP_DESKTOP_FILE" +fi + if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true fi diff --git a/installer/lib_stop_running_hub.sh b/installer/lib_stop_running_hub.sh new file mode 100644 index 0000000..23ad09c --- /dev/null +++ b/installer/lib_stop_running_hub.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# ============================================================================== +# Hermes Hub — общая функция остановки работающего хаба (Linux/POSIX). +# +# До этого файла одна и та же функция была отдельно вписана в install-linux.sh +# и в uninstall-linux.sh — две копии, которые разошлись бы при первой же +# правке одной из них незамеченной для другой. Источник источается («source») +# обоими скриптами и лаунчером остановки, поэтому логика одна. +# +# Использование: `source "$(dirname "$0")/lib_stop_running_hub.sh"`, затем +# вызвать `stop_running_hub`. Функция сама печатает ход дела и возвращает +# 0 (остановлен или нечего было останавливать) либо 1 (что-то осталось — +# вызывающий решает, прерывать ли из-за этого). +# ============================================================================== + +stop_running_hub() { + local pattern="antigravity_provider.router.web|hermes_hub_web_entry" + local pids + # Только процессы ЭТОГО пользователя и только те, что относятся к хабу. + pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr '\n' ' ')" + + if [ -z "$pids" ]; then + echo " Работающий хаб не найден — останавливать нечего." + return 0 + fi + + echo " Найдены процессы хаба: $pids" + # shellcheck disable=SC2086 + kill $pids 2>/dev/null || true + + local waited=0 + while [ "$waited" -lt 10 ]; do + sleep 1 + waited=$((waited + 1)) + pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr '\n' ' ')" + [ -z "$pids" ] && break + done + + if [ -n "$pids" ]; then + echo " Не завершились за 10 секунд, снимаю принудительно: $pids" + # shellcheck disable=SC2086 + kill -9 $pids 2>/dev/null || true + sleep 1 + pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr '\n' ' ')" + fi + + if [ -n "$pids" ]; then + echo " ⚠ Остались процессы: $pids. Снимите их вручную." + return 1 + fi + + echo " Хаб остановлен." + return 0 +} diff --git a/installer/uninstall-linux.sh b/installer/uninstall-linux.sh index e7e8fa3..fb1d2f8 100644 --- a/installer/uninstall-linux.sh +++ b/installer/uninstall-linux.sh @@ -7,6 +7,8 @@ set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + DEFAULT_HERMES_HOME="$HOME/.hermes" HERMES_HOME="${HERMES_HOME:-$DEFAULT_HERMES_HOME}" @@ -23,18 +25,35 @@ echo "======================================================================" echo "Hermes Home : $HERMES_HOME" echo "" +# 0. Остановка работающего хаба. +# +# Тот же порядок, что в install-linux.sh, и по той же причине: файлы под +# работающим процессом здесь не просто устаревают, а исчезают. С +# --purge-user-data это ещё и rm -rf каталогов, на которые у живого процесса +# открыты файловые дескрипторы — на Linux это не роняет процесс, но он +# продолжает отвечать по старому порту после «успешного» удаления, и +# следующая попытка что-то с ним сделать бьётся об уже удалённые файлы. +echo "[0/4] Остановка работающего Hermes Hub..." +# shellcheck source=./lib_stop_running_hub.sh +. "$SCRIPT_DIR/lib_stop_running_hub.sh" +stop_running_hub || true +echo "" + # 1. Remove Plugin Integration -echo "[1/3] Removing plugin integration..." +echo "[1/4] Removing plugin integration..." if [ -d "$HERMES_HOME/plugins/antigravity-provider" ]; then rm -rf "$HERMES_HOME/plugins/antigravity-provider" echo " Removed $HERMES_HOME/plugins/antigravity-provider" fi # 2. Remove Launchers and Shortcuts -echo "[2/3] Removing application launchers and desktop entries..." +echo "[2/4] Removing application launchers and desktop entries..." rm -f "$HOME/.local/bin/hermes-hub-web" rm -f "$HERMES_HOME/bin/hermes-hub-web" +rm -f "$HOME/.local/bin/hermes-hub-stop" +rm -f "$HOME/.local/bin/lib_stop_running_hub.sh" rm -f "$HOME/.local/share/applications/hermes-hub-web.desktop" +rm -f "$HOME/.local/share/applications/hermes-hub-stop.desktop" if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true @@ -42,17 +61,26 @@ fi # 3. User Data Handling if [ "$PURGE_USER_DATA" = "true" ]; then - echo "[3/3] Purging user data (--purge-user-data specified)..." + echo "[3/4] Purging user data (--purge-user-data specified)..." rm -f "$HERMES_HOME/config/router_profiles.yaml" rm -rf "$HERMES_HOME/agy_profiles" rm -rf "$HERMES_HOME/codex_profiles" rm -rf "$HERMES_HOME/opencode_profiles" echo " User configuration and profiles purged." else - echo "[3/3] Preserving user data and credentials." + echo "[3/4] Preserving user data and credentials." echo " Your router profiles, auth keys, and settings in $HERMES_HOME remain intact." fi +# 4. Post-uninstall verification: пойманный хаб действительно молчит. +echo "[4/4] Verifying no hub process remains..." +REMAINING="$(pgrep -u "$(id -u)" -f "antigravity_provider.router.web|hermes_hub_web_entry" 2>/dev/null | tr '\n' ' ')" +if [ -n "$REMAINING" ]; then + echo " ⚠ Всё ещё работает: $REMAINING — удаление файлов это не остановило." +else + echo " Хаб не работает." +fi + echo "" echo "======================================================================" echo " HERMES HUB UNINSTALLED SUCCESSFULLY FROM LINUX " diff --git a/launcher/hermes-hub-stop.sh b/launcher/hermes-hub-stop.sh new file mode 100644 index 0000000..03eb744 --- /dev/null +++ b/launcher/hermes-hub-stop.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# ============================================================================== +# Hermes Hub — Stop (Linux) +# +# На Windows фоновый сервер запускается из HermesHubWeb.exe, который держит +# значок в системном трее — оттуда «Exit» останавливает процесс. На Linux +# сервер стартует через nohup и остаётся в фоне после закрытия окна браузера +# (так и задумано: не переустанавливать при каждом перезапуске окна), но +# остановить его после этого было решительно нечем — ни кнопки в интерфейсе +# (её нет ни на одной платформе), ни трея, ни пункта меню. Только терминал и +# pkill вручную, либо переустановка/удаление, которые останавливают хаб +# только как побочный эффект. +# +# Этот скрипт — тот недостающий эквивалент «Exit из трея»: доступен из меню +# приложений через собственный .desktop-пункт, использует ту же проверенную +# функцию остановки, что installer/install-linux.sh и uninstall-linux.sh. +# ============================================================================== + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Устанавливается рядом (в ~/.hermes/bin) install-linux.sh — оттуда и берём +# общую функцию. Если скрипт запущен не из установленного места (например, +# прямо из репозитория), ищем installer/ на уровень выше. +LIB="" +for candidate in \ + "$SCRIPT_DIR/lib_stop_running_hub.sh" \ + "$SCRIPT_DIR/../installer/lib_stop_running_hub.sh" +do + if [ -f "$candidate" ]; then + LIB="$candidate" + break + fi +done + +if [ -z "$LIB" ]; then + echo "❌ Не найдена installer/lib_stop_running_hub.sh — переустановите Hermes Hub." >&2 + exit 1 +fi + +# shellcheck source=../installer/lib_stop_running_hub.sh +. "$LIB" + +echo "Останавливаю Hermes Hub..." +if stop_running_hub; then + exit 0 +fi +exit 1 diff --git a/tests/test_installer_windows_and_linux.py b/tests/test_installer_windows_and_linux.py index 44aa558..37af70a 100644 --- a/tests/test_installer_windows_and_linux.py +++ b/tests/test_installer_windows_and_linux.py @@ -87,6 +87,17 @@ def test_linux_installer_script_structure(): assert "HERMES_HOME" in install_sh assert "antigravity-provider" in install_sh + # Значок .desktop-записи должен быть растровым (PNG/SVG), не .ico. + # + # Измерено на настоящем GTK-рабочем столе: GdkPixbuf.Pixbuf.new_from_file + # на .ico падает с "Compressed icons are not supported", а .desktop-файл + # с нерендерящейся иконкой меню приложений и файловый менеджер просто + # показывают пустым — без ошибки, молча. HermesHub.ico в тех же ассетах + # существует и раньше подставлялся сюда, поэтому мало проверить, что путь + # не пуст — нужно, чтобы это не был именно .ico. + icon_line = next(line for line in install_sh.splitlines() if line.startswith("ICON_PATH=")) + assert ".ico" not in icon_line, f"иконка .desktop-записи — .ico, GTK его не рендерит: {icon_line!r}" + def test_linux_launcher_script_headless_and_app_mode(): """Verify hermes-hub-web.sh checks DISPLAY, prints SSH port forwarding on headless, and uses --app on desktop.""" @@ -111,3 +122,49 @@ def test_linux_uninstaller_preserves_user_data(): assert "--purge-user-data" in uninstall_sh assert "Preserving user data and credentials" in uninstall_sh assert "hermes-hub-web.desktop" in uninstall_sh + + +def test_linux_uninstaller_stops_running_hub(): + """A61/A62: uninstall-linux.sh must stop the hub before deleting its files. + + Раньше удаление не трогало работающий процесс: rm -rf уходил под живым + сервером, а с --purge-user-data ещё и сносил каталоги, на которые у + процесса были открыты файловые дескрипторы. Хаб продолжал отвечать по + старому порту после «успешного» удаления. Измерено живым прогоном: + сервер, запущенный в песочнице, оставался в списке процессов после + uninstall-linux.sh до этой правки. + """ + uninstall_sh = (INSTALLER_DIR / "uninstall-linux.sh").read_text(encoding="utf-8") + assert "lib_stop_running_hub.sh" in uninstall_sh + assert "stop_running_hub" in uninstall_sh + + +def test_linux_stop_launcher_exists_and_reuses_shared_logic(): + """A61/A62: должен существовать способ остановить хаб не из терминала руками. + + На Windows это «Exit» из системного трея HermesHubWeb.exe. На Linux до + этого не было ничего — ни кнопки в интерфейсе (её нет ни на одной + платформе), ни трея, ни пункта меню: сервер, оставленный в фоне после + закрытия окна браузера, можно было остановить только pkill'ом из + терминала. launcher/hermes-hub-stop.sh — недостающий эквивалент, + устанавливается install-linux.sh как второй пункт меню приложений. + """ + stop_sh = (LAUNCHER_DIR / "hermes-hub-stop.sh").read_text(encoding="utf-8") + assert "#!/usr/bin/env bash" in stop_sh + assert "stop_running_hub" in stop_sh + + lib_sh = (INSTALLER_DIR / "lib_stop_running_hub.sh").read_text(encoding="utf-8") + assert "stop_running_hub()" in lib_sh + # Общий источник, а не третья копия той же функции: install и uninstall + # обязаны ссылаться на тот же файл, а не хранить свою версию. + install_sh = (INSTALLER_DIR / "install-linux.sh").read_text(encoding="utf-8") + uninstall_sh = (INSTALLER_DIR / "uninstall-linux.sh").read_text(encoding="utf-8") + for script_name, script_text in (("install-linux.sh", install_sh), ("uninstall-linux.sh", uninstall_sh)): + assert "lib_stop_running_hub.sh" in script_text, f"{script_name} не источает общую функцию" + assert script_text.count("stop_running_hub() {") == 0, ( + f"{script_name} держит собственную копию функции вместо общего источника" + ) + + assert "hermes-hub-stop" in install_sh, "install-linux.sh не разворачивает лаунчер остановки" + assert "hermes-hub-stop.desktop" in install_sh, "у лаунчера остановки нет пункта меню" + assert "hermes-hub-stop" in uninstall_sh, "uninstall-linux.sh не убирает лаунчер остановки" -- 2.45.2 From 377c567b859c582165226cd7d69873ac77a60b23 Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:57:08 +0700 Subject: [PATCH 12/12] =?UTF-8?q?fix(installer,release):=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=D0=BA=D0=B8=20agy=20=D1=81=20=D0=B6=D0=B8?= =?UTF-8?q?=D0=B2=D0=BE=D0=B9=20Windows-=D0=BC=D0=B0=D1=88=D0=B8=D0=BD?= =?UTF-8?q?=D1=8B=20(A61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ветка installer/a61-live-verification (agy, коммит b0644a3) частично пересекалась с HUB-1, частично добавляла то, чего в HUB-1 не было. Пункты взяты по одному, дубли — нет. ## Взято 1. verify_multi_provider_router.py проверял изоляцию пути на ID "ag-w2" — на живой машине владельца это существующий подключённый профиль, и "assert not pdir.exists()" падал не из-за бага, а потому что каталог реального аккаунта и так был на месте (код возврата 12). ID заменён на заведомо не боевой "ag-probe-isolation-test". 2. HermesHubSetup.cs: CreateStartMenuShortcut/RemoveStartMenuShortcut не уважали HERMES_HUB_NO_REGISTRY — переменная гасила запись в реестр (A4), но ярлык в настоящем меню Пуск изолированные тесты всё равно писали. Добавлена та же проверка, что уже стоит перед записью в реестр. Заодно LOCALAPPDATA читается из окружения раньше SpecialFolder — расхождение найдено живым прогоном. 3. test_installer.py: /silent-тест линковался на venv настоящей машины junction'ом (Windows) или symlink'ом вместо пустых touch-файлов — раньше проверка живых Win32-зависимостей ничего по сути не проверяла. 4. update_manager.py: запасной перебор известных имён установщика (hermes-hub-setup.sh/install-linux.sh/HermesHubSetup.exe) до отката на .zip — подстраховка на случай расхождения определения платформы. 5. release_gate.py: --assets проверял только присутствие файлов. Добавлена нижняя граница размера (усечённая сборка, найдено вживую) и сверка SHA-256 каждого установщика с локальным checksums.txt — до всякой публикации. Своя реализация (agy: только HermesHubSetup.exe, только argparse-обвязка, несовместимая с --publication-only из HUB-1), но идея и обе живые находки — его. Проверено полным циклом: собран настоящий dist/hermes-hub-setup.sh, посчитаны настоящие контрольные суммы, --assets прошёл на них 12588887 байт, SHA-256 сошёлся. ## Не взято — уже есть шире в HUB-1 - security_guard.py: точечный "$HOME" в тексте команды вместо конвейера (диалект по команде, ${HOME}, %USERPROFILE%, fail-closed) — версия HUB-1 шире и уже зелёная на настоящем Windows CI. - test_a59: заглушка os.getuid без проверки ветки Windows (taskkill/wmic) — версия HUB-1 параметризована на обе ветки. - Скомпилированные .exe — не переношу: пересборка на Windows после этого коммита, здесь compилятора нет. Тесты: 780 -> 781 passed, 2 skipped, 4 deselected. ruff check . чисто. Co-Authored-By: Claude Sonnet 5 --- installer/HermesHubSetup.cs | 19 +++++- scripts/release_gate.py | 49 ++++++++++++++- scripts/verify_multi_provider_router.py | 11 +++- .../updater/update_manager.py | 13 ++++ tests/test_installer.py | 30 +++++++-- tests/test_p0_release_gate.py | 61 ++++++++++++++++--- 6 files changed, 166 insertions(+), 17 deletions(-) diff --git a/installer/HermesHubSetup.cs b/installer/HermesHubSetup.cs index 4de074e..23cf80c 100644 --- a/installer/HermesHubSetup.cs +++ b/installer/HermesHubSetup.cs @@ -86,7 +86,17 @@ namespace HermesHubSetup } } - string defaultTarget = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\HermesHub"); + // GetFolderPath(LocalApplicationData) не всегда возвращает то, что + // ждёт установщик — найдено живым прогоном (A61): в некоторых + // окружениях (изолированный тестовый профиль, нестандартный + // пользовательский куст реестра) значение расходится с + // фактическим %LOCALAPPDATA%. Читаем переменную окружения первой. + string localAppData = Environment.GetEnvironmentVariable("LOCALAPPDATA"); + if (string.IsNullOrEmpty(localAppData)) + { + localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + } + string defaultTarget = Path.Combine(localAppData, @"Programs\HermesHub"); TargetInstallDir = defaultTarget; // Check if already installed @@ -595,6 +605,12 @@ namespace HermesHubSetup private static void CreateStartMenuShortcut() { + // Изолированные прогоны (HERMES_HUB_NO_REGISTRY=1) уже не пишут в + // реестр (см. HERMES_HUB_NO_REGISTRY ниже), но ярлык в настоящем + // меню Пуск владельца этим не перекрывался — найдено живым + // прогоном тестов на A61: /silent-тест с этой переменной всё + // равно оставлял значок в реальном Пуск. + if (Environment.GetEnvironmentVariable("HERMES_HUB_NO_REGISTRY") == "1") return; try { string startMenu = Environment.GetFolderPath(Environment.SpecialFolder.Programs); @@ -641,6 +657,7 @@ namespace HermesHubSetup private static void RemoveStartMenuShortcut() { + if (Environment.GetEnvironmentVariable("HERMES_HUB_NO_REGISTRY") == "1") return; try { string startMenu = Environment.GetFolderPath(Environment.SpecialFolder.Programs); diff --git a/scripts/release_gate.py b/scripts/release_gate.py index 2198931..15e5faf 100644 --- a/scripts/release_gate.py +++ b/scripts/release_gate.py @@ -241,6 +241,13 @@ CHECKSUMS_ASSET_NAME = "checksums.txt" # ассет не должен превращать ворота в отказ в обслуживании самим себе. MAX_PACKAGE_BYTES = 512 * 1024 * 1024 +# Нижняя граница размера установщика — защита от усечённой сборки. Найдено +# живым прогоном на Windows (A61): собранный HermesHubSetup.exe считался +# готовым к публикации даже будучи почти пустым — сборка прервалась, а файл +# остался. 1 МБ — заведомо меньше любого настоящего установщика (несёт +# исходники плагина вшитым ресурсом), но отличает пустышку от файла. +MIN_PACKAGE_BYTES = 1024 * 1024 + def is_publication_mode() -> bool: """Требуется ли блокирующая проверка публикации.""" @@ -272,6 +279,21 @@ def _download_and_hash(url: str) -> tuple[str, int]: return digest.hexdigest(), size +def _hash_local_file(path: Path) -> tuple[str, int]: + """Посчитать SHA-256 локального файла целиком. Размер — побочный продукт.""" + import hashlib + digest = hashlib.sha256() + size = 0 + with open(path, "rb") as f: + while True: + chunk = f.read(1024 * 256) + if not chunk: + break + size += len(chunk) + digest.update(chunk) + return digest.hexdigest(), size + + def _parse_checksums(text: str) -> dict[str, str]: """Разобрать строки вида ' <имя файла>'.""" table: dict[str, str] = {} @@ -390,6 +412,12 @@ def check_publishable_assets(dist_dir: Path) -> tuple[bool, str]: на тех же двух дефектах, что и CI: каждый его прогон завершался ошибкой, а релизы публиковались мимо него. Как только тесты позеленели, случайная защита исчезла — поэтому набор проверяется явно. + + Помимо присутствия файлов — размер и хеш КАЖДОГО найденного установщика + против локального checksums.txt. Найдено живым прогоном на Windows + (A61): сборка может прерваться на середине и оставить усечённый файл, а + checksums.txt и сам установщик могут разойтись ещё до всякой публикации. + Проверка одного присутствия этого не ловит. """ if not dist_dir.is_dir(): return False, f"Каталог сборки не найден: {dist_dir}" @@ -413,7 +441,26 @@ def check_publishable_assets(dist_dir: Path) -> tuple[bool, str]: f"installer/build_installer.ps1 и installer/build_installer_linux.sh" ) - return True, f"Набор ассетов пригоден для публикации: {installers} + {CHECKSUMS_ASSET_NAME}" + local_checksums = _parse_checksums((dist_dir / CHECKSUMS_ASSET_NAME).read_text(encoding="utf-8-sig", errors="replace")) + verified = [] + for name in installers: + actual_hash, size = _hash_local_file(dist_dir / name) + if size < MIN_PACKAGE_BYTES: + return False, ( + f"{name} подозрительно мал ({size} байт, ожидался хотя бы {MIN_PACKAGE_BYTES}) " + f"— похоже на прерванную сборку" + ) + expected_hash = local_checksums.get(name) + if not expected_hash: + return False, f"Для {name} нет строки в {CHECKSUMS_ASSET_NAME} — сверить хеш не с чем" + if actual_hash != expected_hash: + return False, ( + f"SHA-256 {name} не сошёлся с {CHECKSUMS_ASSET_NAME}: " + f"файл {actual_hash}, записан {expected_hash}" + ) + verified.append(f"{name} ({size} байт, SHA-256 сошёлся)") + + return True, f"Набор ассетов пригоден для публикации: {', '.join(verified)}" def run_release_gate(): diff --git a/scripts/verify_multi_provider_router.py b/scripts/verify_multi_provider_router.py index 919935d..10702d8 100644 --- a/scripts/verify_multi_provider_router.py +++ b/scripts/verify_multi_provider_router.py @@ -148,8 +148,15 @@ def run_checks() -> int: # Проверяем изоляцию пути, а не побочное создание каталога: запрос пути # каталогов больше не плодит, иначе любая проверка засоряла бы диск # десятком пустых слотов. - pdir = get_profile_env_dir("ag-w2") - assert "ag-w2" in str(pdir) + # + # ID заведомо не боевой. Было "ag-w2" — на живой машине владельца это + # существующий подключённый профиль, и "assert not pdir.exists()" падал + # не из-за бага, а потому что каталог реального аккаунта и так был на + # месте. Найдено прогоном на настоящей установке (A61): скрипт возвращал + # код 12, хотя изоляция путей работала верно. + probe_id = "ag-probe-isolation-test" + pdir = get_profile_env_dir(probe_id) + assert probe_id in str(pdir) assert "agy_profiles" in str(pdir) assert not pdir.exists(), "запрос пути не должен создавать каталог" print(f" [PASS] Profile directory isolated at {pdir} (не создан)") diff --git a/src/antigravity_provider/updater/update_manager.py b/src/antigravity_provider/updater/update_manager.py index e3933ba..28dbf65 100644 --- a/src/antigravity_provider/updater/update_manager.py +++ b/src/antigravity_provider/updater/update_manager.py @@ -1031,6 +1031,19 @@ class UpdateManager: chosen_url = assets[linux_name] break + # Платформа не распознана штатной веткой (is_win разошёлся с тем, что + # реально есть в релизе) — переберём любое известное имя установщика, + # прежде чем сдаваться на .zip. Найдено живым прогоном (A61): без + # этой подстраховки единственная опечатка в определении платформы + # роняла обновление с «не найден подходящий файл», хотя нужный + # установщик в релизе был. + if not chosen_url: + for known_name in ("hermes-hub-setup.sh", "install-linux.sh", "HermesHubSetup.exe"): + if known_name in assets: + chosen_asset_name = known_name + chosen_url = assets[known_name] + break + # Fallback to any .zip package in assets or manifest package_url if not chosen_url: for a_name, a_url in assets.items(): diff --git a/tests/test_installer.py b/tests/test_installer.py index d8e7d28..dfc9181 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import os import subprocess +import sys from pathlib import Path import pytest @@ -39,12 +40,29 @@ def test_silent_installer_execution_with_hermes(tmp_path): if not SETUP_EXE.is_file(): pytest.skip("HermesHubSetup.exe not built yet") - # Set up mock Hermes Agent structure in temp home + # Set up mock Hermes Agent structure in temp home pointing to the active venv. + # + # Пустые touch-файлы python.exe/hermes.exe раньше делали живую проверку + # зависимостей на установленной машине бессмысленной: файл существовал, + # но не был исполняемым питоном, и по-настоящему установщик его не + # запускал ни разу. Найдено прогоном на настоящей машине (A61): junction + # (или symlink) на реальный venv делает эту проверку осмысленной — + # запускается настоящий интерпретатор. agent_dir = tmp_path / "hermes" / "hermes-agent" - venv_scripts = agent_dir / "venv" / "Scripts" - venv_scripts.mkdir(parents=True, exist_ok=True) - (venv_scripts / "python.exe").touch() - (venv_scripts / "hermes.exe").touch() + agent_dir.mkdir(parents=True, exist_ok=True) + real_venv = Path(sys.prefix) + target_venv = agent_dir / "venv" + try: + import _winapi + _winapi.CreateJunction(str(real_venv), str(target_venv)) + except Exception: + try: + os.symlink(str(real_venv), str(target_venv), target_is_directory=True) + except Exception: + venv_scripts = target_venv / "Scripts" + venv_scripts.mkdir(parents=True, exist_ok=True) + (venv_scripts / "python.exe").touch() + (venv_scripts / "hermes.exe").touch() env = dict(os.environ) env["HERMES_HOME"] = str(tmp_path / "hermes") @@ -54,7 +72,7 @@ def test_silent_installer_execution_with_hermes(tmp_path): env["HERMES_HUB_NO_REGISTRY"] = "1" res = subprocess.run([str(SETUP_EXE), "/silent"], env=env, capture_output=True, text=True) - assert res.returncode == 0, f"Expected returncode 0, got {res.returncode}. Stderr: {res.stderr}" + assert res.returncode == 0, f"Expected returncode 0, got {res.returncode}. Stderr: {res.stderr}. Stdout: {res.stdout}" @pytest.mark.installer diff --git a/tests/test_p0_release_gate.py b/tests/test_p0_release_gate.py index 430501c..7b2e613 100644 --- a/tests/test_p0_release_gate.py +++ b/tests/test_p0_release_gate.py @@ -653,12 +653,59 @@ def test_publishable_assets_check_rejects_uninstallable_release(tmp_path): assert ok is False, "набор без checksums.txt признан пригодным" assert "checksums.txt" in msg - as_published_really = tmp_path / "dist_full" - as_published_really.mkdir() - for name in ("HermesHubSetup.exe", "hermes-hub-setup.sh", "checksums.txt"): - (as_published_really / name).write_bytes(b"x") - ok, msg = release_gate.check_publishable_assets(as_published_really) - assert ok is True, msg - ok, msg = release_gate.check_publishable_assets(tmp_path / "нет-такого") assert ok is False, "отсутствующий каталог сборки должен быть отказом" + + +def _write_installer_with_checksum(directory, name: str, content: bytes) -> None: + """Собрать + checksums.txt с настоящим SHA-256 для теста.""" + import hashlib + + (directory / name).write_bytes(content) + digest = hashlib.sha256(content).hexdigest() + (directory / "checksums.txt").write_text(f"{digest} {name}\n", encoding="utf-8") + + +@pytest.mark.unit +def test_publishable_assets_check_verifies_size_and_hash(tmp_path): + """A61: присутствия файлов мало — размер и хеш проверяются по-настоящему. + + Найдено живым прогоном на Windows: сборка может прерваться на середине и + оставить усечённый файл, а checksums.txt и сам установщик — разойтись ещё + до всякой публикации. Проверка одного присутствия этого не ловит. + """ + release_gate = _load_release_gate() + + # 1. Настоящий размер, настоящий хеш — проходит. + good = tmp_path / "dist_good" + good.mkdir() + _write_installer_with_checksum(good, "HermesHubSetup.exe", b"X" * release_gate.MIN_PACKAGE_BYTES) + ok, msg = release_gate.check_publishable_assets(good) + assert ok is True, msg + assert "SHA-256 сошёлся" in msg + + # 2. Файл меньше нижней границы — похоже на прерванную сборку. + truncated = tmp_path / "dist_truncated" + truncated.mkdir() + _write_installer_with_checksum(truncated, "HermesHubSetup.exe", b"x" * 10) + ok, msg = release_gate.check_publishable_assets(truncated) + assert ok is False, "усечённый установщик признан пригодным к публикации" + assert "мал" in msg + + # 3. Хеш в checksums.txt не совпадает с настоящим файлом. + mismatched = tmp_path / "dist_mismatch" + mismatched.mkdir() + content = b"Y" * release_gate.MIN_PACKAGE_BYTES + (mismatched / "HermesHubSetup.exe").write_bytes(content) + (mismatched / "checksums.txt").write_text("0" * 64 + " HermesHubSetup.exe\n", encoding="utf-8") + ok, msg = release_gate.check_publishable_assets(mismatched) + assert ok is False, "разошедшийся хеш признан пригодным к публикации" + assert "не сошёлся" in msg + + # 4. checksums.txt не называет установленный файл вовсе. + unnamed = tmp_path / "dist_unnamed" + unnamed.mkdir() + (unnamed / "HermesHubSetup.exe").write_bytes(b"Z" * release_gate.MIN_PACKAGE_BYTES) + (unnamed / "checksums.txt").write_text("0" * 64 + " other-file.bin\n", encoding="utf-8") + ok, msg = release_gate.check_publishable_assets(unnamed) + assert ok is False, "checksums.txt без нужной строки признан достаточным" -- 2.45.2