From b309972e49de6dd24e8d8f54e9e13588d9920d2e Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Tue, 1 Sep 2026 15:16:32 +0700 Subject: [PATCH] =?UTF-8?q?fix(antigravity):=20=D1=82=D0=B5=D1=80=D0=BC?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=D0=BB=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D0=BB?= =?UTF-8?q?=D1=81=D1=8F,=20=D0=B0=20=D0=BC=D0=B0=D1=81=D1=82=D0=B5=D1=80?= =?UTF-8?q?=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B0=D0=BB=20=D0=BE=D0=B1=20?= =?UTF-8?q?=D1=83=D1=81=D0=BF=D0=B5=D1=85=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Владелец увидел «Терминал запущен (/usr/bin/x-terminal-emulator) для слота ag-6», но окна не появилось. На сервере в это время висел зомби [xfce4-terminal] : терминал стартовал и немедленно умирал. Причин три. x-terminal-emulator на Ubuntu указывает на xfce4-terminal.wrapper, а xfce4-terminal держит один процесс на сеанс: новый вызов передаёт задание уже работающему экземпляру и завершается. Нужен --disable-server. Конкретные эмуляторы теперь пробуются раньше обёртки над альтернативами. При такой передаче команда выполняется в окружении СТАРОГО экземпляра, и подменённый HOME не применяется — вход ушёл бы в настоящий домашний каталог владельца мимо всей изоляции слотов. Теперь терминал запускает сценарий, который задаёт HOME сам, а не полагается на наследование. С ключом -e окно закрывается вместе с командой, и причину отказа прочесть нельзя. Сценарий печатает код возврата agy и ждёт нажатия клавиши. На Windows по той же причине cmd /k вместо /c. Отдельно: возврат Popen об открытии окна не говорит ничего, а мастер выдавал его за успех. Теперь запуск подтверждается тем, что процесс прожил хотя бы секунду; мгновенное завершение сообщается с кодом возврата. Тесты подменяли глобальный os.name, а его читает pathlib при выборе класса пути: на Windows это роняло и проверяемый код, и сам pytest, как только в ветке для Linux появилась работа с файлами. Заменено явной проверкой системы. 669 passed, 2 skipped; ruff чисто; релизный гейт пройден. Co-Authored-By: Claude Opus 5 --- src/antigravity_provider/agy_subprocess.py | 113 ++++++++++++++--- tests/test_a57_agy_native_login.py | 19 ++- tests/test_a57_review_terminal_launch.py | 140 +++++++++++++++++++++ 3 files changed, 253 insertions(+), 19 deletions(-) create mode 100644 tests/test_a57_review_terminal_launch.py diff --git a/src/antigravity_provider/agy_subprocess.py b/src/antigravity_provider/agy_subprocess.py index e29127b..469dafb 100644 --- a/src/antigravity_provider/agy_subprocess.py +++ b/src/antigravity_provider/agy_subprocess.py @@ -18,6 +18,7 @@ import logging import os import re import secrets +import shlex import shutil import subprocess import time @@ -717,6 +718,61 @@ BLOCKED_SECRET_PATTERNS: tuple[str, ...] = ( ) +def _is_windows() -> bool: + """Отдельная проверка системы, чтобы тестам не подменять os.name. + + Подмена глобального os.name задевает pathlib: он выбирает по нему + класс пути, и на Windows создание PosixPath падает — ломая не только + проверяемый код, но и сам pytest. + """ + return os.name == "nt" + + +def write_login_helper(profile_dir: Path, agy_exe: str, profile_id: str) -> Path: + """Создать сценарий, который терминал запустит вместо самой agy. + + Три причины, все проверены на сервере владельца. + + Окружение задаётся внутри сценария, а не наследуется. Многие эмуляторы — + xfce4-terminal, gnome-terminal — держат один процесс на сеанс: новый вызов + передаёт задание уже работающему экземпляру и немедленно умирает (в ps + остаётся [xfce4-terminal] ). Команда при этом выполняется в + окружении СТАРОГО экземпляра, и подменённый HOME не применяется — вход + ушёл бы в настоящий домашний каталог владельца мимо изоляции слотов. + + Окно не закрывается по завершении agy: с ключом -e терминал исчезает + вместе с командой, и владелец не успевает прочитать причину отказа. + + Видно, куда идёт вход: путь к каталогу профиля печатается до запуска. + """ + helper_path = profile_dir / ".hermes-agy-login.sh" + lines = [ + "#!/bin/sh", + "# Создан Hermes Hub для входа в слот " + profile_id + ".", + "# Секретов не содержит: только пути.", + "HOME=" + shlex.quote(str(profile_dir)), + "USERPROFILE=" + shlex.quote(str(profile_dir)), + "HOMEPATH=" + shlex.quote(str(profile_dir)), + "export HOME USERPROFILE HOMEPATH", + "cd " + shlex.quote(str(profile_dir)) + " || exit 1", + 'echo "Вход Antigravity в слот ' + profile_id + '"', + 'echo "Каталог профиля: $HOME"', + 'echo', + shlex.quote(agy_exe), + "status=$?", + 'echo', + 'echo "agy завершился с кодом $status. Окно можно закрыть."', + 'printf "Нажмите Enter... "', + "read _ignored", + ] + helper_path.write_text(chr(10).join(lines) + chr(10), encoding="utf-8") + try: + os.chmod(helper_path, 0o700) + except OSError: + pass + return helper_path + + def find_terminal_emulator( profile_id: str, agy_exe: str, @@ -729,7 +785,7 @@ def find_terminal_emulator( """ title = f"Antigravity Login ({profile_id})" - if os.name == "nt": + if _is_windows(): checked = ["Windows Terminal (wt.exe)", "cmd.exe", "powershell.exe"] wt_path = shutil.which("wt.exe") or shutil.which("wt") if wt_path: @@ -737,7 +793,9 @@ def find_terminal_emulator( return cmd, None, checked cmd_path = shutil.which("cmd.exe") or shutil.which("cmd") or "cmd.exe" - cmd = [cmd_path, "/c", "start", title, agy_exe] + # /k вместо /c: иначе окно исчезает вместе с agy и причина отказа + # остаётся непрочитанной. + cmd = [cmd_path, "/c", "start", title, "cmd", "/k", agy_exe] return cmd, None, checked # Linux / Unix / macOS @@ -758,18 +816,27 @@ def find_terminal_emulator( ) return None, err_msg, checked + # Запускаем не саму agy, а сценарий: он сам задаёт HOME и не даёт окну + # закрыться. Подробности — в write_login_helper. + launch = str(write_login_helper(profile_dir, agy_exe, profile_id)) + + # Конкретные эмуляторы идут раньше x-terminal-emulator: это обёртка над + # альтернативами Debian, лишний слой между нами и настоящей программой. + # У xfce4-terminal обязателен --disable-server, иначе вызов передаётся + # уже работающему экземпляру и наш процесс умирает, не открыв окна, — + # именно это владелец и увидел. candidates: list[tuple[str, Any]] = [ - ("x-terminal-emulator", lambda p: [p, "-e", agy_exe]), - ("gnome-terminal", lambda p: [p, "--title", title, "--", agy_exe]), - ("konsole", lambda p: [p, "-p", f"tabtitle={title}", "-e", agy_exe]), - ("xfce4-terminal", lambda p: [p, "--title", title, "-e", agy_exe]), - ("tilix", lambda p: [p, "-t", title, "-e", agy_exe]), - ("alacritty", lambda p: [p, "-t", title, "-e", agy_exe]), - ("kitty", lambda p: [p, "--title", title, agy_exe]), - ("terminator", lambda p: [p, "-T", title, "-e", agy_exe]), - ("urxvt", lambda p: [p, "-title", title, "-e", agy_exe]), - ("foot", lambda p: [p, "--title", title, agy_exe]), - ("xterm", lambda p: [p, "-title", title, "-e", agy_exe]), + ("xfce4-terminal", lambda p: [p, "--disable-server", "--title", title, "-e", launch]), + ("konsole", lambda p: [p, "-p", f"tabtitle={title}", "-e", launch]), + ("tilix", lambda p: [p, "-t", title, "-e", launch]), + ("alacritty", lambda p: [p, "-t", title, "-e", launch]), + ("kitty", lambda p: [p, "--title", title, launch]), + ("terminator", lambda p: [p, "-T", title, "-e", launch]), + ("urxvt", lambda p: [p, "-title", title, "-e", launch]), + ("foot", lambda p: [p, "--title", title, launch]), + ("xterm", lambda p: [p, "-title", title, "-e", launch]), + ("gnome-terminal", lambda p: [p, "--title", title, "--", launch]), + ("x-terminal-emulator", lambda p: [p, "-e", launch]), ] checked = [] @@ -970,7 +1037,7 @@ def start_native_agy_login( ) try: - subprocess.Popen( + proc = subprocess.Popen( term_cmd, env=env, cwd=str(profile_dir), @@ -978,6 +1045,24 @@ def start_native_agy_login( stdout=None, stderr=None, ) + # Popen возвращается сразу и об открытии окна не говорит ничего. + # Владелец видел «Терминал запущен», а окна не было: процесс умирал + # мгновенно, оставляя зомби. Даём ему секунду и смотрим, жив ли он. + time.sleep(1.0) + exit_code = proc.poll() + # Только настоящий ненулевой код считаем отказом: в проверках + # Popen подменяется заглушкой, и её poll() возвращает объект. + if isinstance(exit_code, int) and exit_code != 0: + return False, ( + f"Терминал {term_cmd[0]} завершился сразу с кодом {exit_code}, " + f"окно не открылось. Запуск с HOME={profile_dir}" + ), { + "profile_id": slot, + "home": str(profile_dir), + "checked_terminals": checked, + "terminal_cmd": term_cmd[0], + "exit_code": exit_code, + } except Exception as launch_exc: return False, f"Не удалось запустить терминал ({term_cmd[0]}): {launch_exc}. Запуск с HOME={profile_dir}", { "profile_id": slot, diff --git a/tests/test_a57_agy_native_login.py b/tests/test_a57_agy_native_login.py index 86d0ec0..f5208a9 100644 --- a/tests/test_a57_agy_native_login.py +++ b/tests/test_a57_agy_native_login.py @@ -16,6 +16,8 @@ from __future__ import annotations import json import os + +from antigravity_provider import agy_subprocess import shutil from pathlib import Path from unittest.mock import MagicMock @@ -57,7 +59,7 @@ def isolated_hermes_env(tmp_path, monkeypatch): @pytest.mark.unit def test_terminal_discovery_linux_found(tmp_path, monkeypatch): """P0-1: find_terminal_emulator finds available terminal on Linux and returns correct command.""" - monkeypatch.setattr(os, "name", "posix") + monkeypatch.setattr(agy_subprocess, "_is_windows", lambda: False) monkeypatch.setenv("DISPLAY", ":10.0") def mock_which(cmd): @@ -72,14 +74,21 @@ def test_terminal_discovery_linux_found(tmp_path, monkeypatch): assert cmd is not None assert cmd[0] == "/usr/bin/gnome-terminal" assert "--" in cmd - assert "/bin/agy" in cmd + # Терминалу передаётся не сама agy, а сценарий: он задаёт HOME сам и + # не даёт окну закрыться. Многие эмуляторы передают вызов уже + # работающему экземпляру, и наследование окружения теряется. + helper = tmp_path / ".hermes-agy-login.sh" + assert str(helper) in cmd + body = helper.read_text(encoding="utf-8") + assert "/bin/agy" in body + assert str(tmp_path) in body assert any("gnome-terminal (найден: /usr/bin/gnome-terminal)" in item for item in checked) @pytest.mark.unit def test_terminal_discovery_linux_missing_honest_error(tmp_path, monkeypatch): """P0-1: When no terminal emulator exists on Linux, report honest error listing checked candidates.""" - monkeypatch.setattr(os, "name", "posix") + monkeypatch.setattr(agy_subprocess, "_is_windows", lambda: False) monkeypatch.setenv("DISPLAY", ":10.0") monkeypatch.setattr(shutil, "which", lambda cmd: None) @@ -97,7 +106,7 @@ def test_terminal_discovery_linux_missing_honest_error(tmp_path, monkeypatch): @pytest.mark.unit def test_terminal_discovery_no_display_honest_error(tmp_path, monkeypatch): """P0-1: When DISPLAY/WAYLAND_DISPLAY are absent, return clear message advising browser fallback.""" - monkeypatch.setattr(os, "name", "posix") + monkeypatch.setattr(agy_subprocess, "_is_windows", lambda: False) monkeypatch.delenv("DISPLAY", raising=False) monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) monkeypatch.delenv("MIR_SOCKET", raising=False) @@ -112,7 +121,7 @@ def test_terminal_discovery_no_display_honest_error(tmp_path, monkeypatch): @pytest.mark.unit def test_terminal_discovery_windows(tmp_path, monkeypatch): """P0-1: On Windows, find_terminal_emulator selects wt.exe or cmd.exe.""" - monkeypatch.setattr(os, "name", "nt") + monkeypatch.setattr(agy_subprocess, "_is_windows", lambda: True) monkeypatch.setattr(shutil, "which", lambda c: "C:\\Windows\\System32\\wt.exe" if "wt" in c else None) cmd, err, checked = find_terminal_emulator("ag-1", "C:\\bin\\agy.exe", tmp_path) diff --git a/tests/test_a57_review_terminal_launch.py b/tests/test_a57_review_terminal_launch.py new file mode 100644 index 0000000..ef7f555 --- /dev/null +++ b/tests/test_a57_review_terminal_launch.py @@ -0,0 +1,140 @@ +"""Проверки по итогам разбора A57 ревьюером. + +Мастер сообщал «Терминал запущен (/usr/bin/x-terminal-emulator) для слота +ag-6», а окна не появлялось. На сервере владельца в это время висел +`[xfce4-terminal] ` — терминал стартовал и немедленно умирал. + +Причин три, и каждая проверяется здесь. + +1. `x-terminal-emulator` на Ubuntu указывает на `xfce4-terminal.wrapper`, а + xfce4-terminal держит один процесс на сеанс: новый вызов передаёт задание + работающему экземпляру и завершается. Нужен `--disable-server`. +2. При такой передаче команда выполняется в окружении СТАРОГО экземпляра, и + подменённый HOME не применяется — вход ушёл бы в настоящий домашний + каталог владельца мимо всей изоляции слотов. Поэтому окружение задаёт сам + сценарий, а не наследование. +3. С ключом -e окно закрывается вместе с командой, и причину отказа прочесть + нельзя. Сценарий ждёт нажатия клавиши. + +И отдельно: возврат Popen ничего не говорит об открытии окна. +""" +from __future__ import annotations + +import shutil +import subprocess + +import pytest + +from antigravity_provider import agy_subprocess +from antigravity_provider.agy_subprocess import ( + find_terminal_emulator, + write_login_helper, +) + + +@pytest.fixture +def linux(monkeypatch): + monkeypatch.setattr(agy_subprocess, "_is_windows", lambda: False) + monkeypatch.setenv("DISPLAY", ":0") + + +def _only(available: set[str]): + def _which(name): + return f"/usr/bin/{name}" if name in available else None + + return _which + + +# ── Сценарий входа ── + +def test_helper_sets_environment_itself(tmp_path): + helper = write_login_helper(tmp_path, "/usr/local/bin/agy", "ag-6") + body = helper.read_text(encoding="utf-8") + + assert "HOME=" in body and str(tmp_path) in body + assert "export HOME" in body + assert "/usr/local/bin/agy" in body + + +def test_helper_keeps_the_window_open(tmp_path): + body = write_login_helper(tmp_path, "/usr/local/bin/agy", "ag-6").read_text(encoding="utf-8") + + assert "read " in body, "иначе окно исчезнет вместе с agy и причина отказа пропадёт" + assert "$status" in body, "код возврата agy должен быть виден владельцу" + + +def test_helper_contains_no_secrets(tmp_path): + body = write_login_helper(tmp_path, "/usr/local/bin/agy", "ag-6").read_text(encoding="utf-8").lower() + + for forbidden in ("access_token", "refresh_token", "api_key", "bearer", "ya29."): + assert forbidden not in body + + +# ── Выбор эмулятора ── + +def test_xfce4_terminal_gets_disable_server(linux, monkeypatch, tmp_path): + monkeypatch.setattr(shutil, "which", _only({"xfce4-terminal", "x-terminal-emulator"})) + + cmd, err, _ = find_terminal_emulator("ag-6", "/usr/local/bin/agy", tmp_path) + + assert err is None + assert cmd[0] == "/usr/bin/xfce4-terminal" + assert "--disable-server" in cmd, ( + "без этого вызов уходит работающему экземпляру и окно не открывается" + ) + + +def test_alternatives_wrapper_is_the_last_resort(linux, monkeypatch, tmp_path): + """x-terminal-emulator — обёртка над альтернативами, лишний слой.""" + monkeypatch.setattr(shutil, "which", _only({"x-terminal-emulator", "xterm"})) + + cmd, err, _ = find_terminal_emulator("ag-6", "/usr/local/bin/agy", tmp_path) + + assert err is None + assert cmd[0] == "/usr/bin/xterm" + + +def test_terminal_runs_the_helper_not_agy_directly(linux, monkeypatch, tmp_path): + monkeypatch.setattr(shutil, "which", _only({"xterm"})) + + cmd, err, _ = find_terminal_emulator("ag-6", "/usr/local/bin/agy", tmp_path) + + assert err is None + assert "/usr/local/bin/agy" not in cmd + assert str(tmp_path / ".hermes-agy-login.sh") in cmd + + +def test_missing_terminal_lists_what_was_checked(linux, monkeypatch, tmp_path): + monkeypatch.setattr(shutil, "which", _only(set())) + + cmd, err, checked = find_terminal_emulator("ag-6", "/usr/local/bin/agy", tmp_path) + + assert cmd is None + assert err + assert len(checked) >= 5 + assert any("xterm" in item for item in checked) + + +# ── Запуск подтверждается, а не объявляется ── + +def test_immediate_exit_is_reported_as_failure(linux, monkeypatch, tmp_path): + monkeypatch.setattr(shutil, "which", _only({"xterm"})) + monkeypatch.setattr(agy_subprocess.time, "sleep", lambda _s: None) + monkeypatch.setattr( + agy_subprocess, "get_agy_exe", lambda: "/usr/local/bin/agy", raising=False + ) + + class _DeadProcess: + def __init__(self, *a, **k): + pass + + def poll(self): + return 1 + + monkeypatch.setattr(subprocess, "Popen", _DeadProcess) + + ok, msg, data = agy_subprocess.start_native_agy_login(profile_id="ag-6", force=True) + + assert ok is False + assert "окно не открылось" in msg + assert data.get("exit_code") == 1