hermes-hub/tests/test_a57_review_terminal_launch.py
Hermes Team 28f35f863f fix(antigravity): терминал не открывался из-за подменённого HOME; видно работающую сборку
Мастер честно сообщил: «Терминал /usr/bin/xfce4-terminal завершился сразу с
кодом 1, окно не открылось» — новая проверка запуска сработала.

Причина в моей же правке. Терминал запускался с HOME, подменённым на каталог
профиля, а клиенты X11 берут ключ авторизации из ~/.Xauthority: в
agy_profiles/ag-5 такого файла нет и быть не может, поэтому подключиться к
дисплею терминал не мог. Подменять HOME терминалу и не требуется — это делает
сценарий входа, уже внутри окна, перед самым запуском agy.

Заодно устранено побочное действие в запросе: сценарий входа создавался внутри
функции ПОИСКА терминала. Теперь его готовит start_native_agy_login, а поиск
только отвечает на вопрос и ничего не создаёт.

Владелец не мог понять, обновился ли хаб: на Linux строка сборки в боковой
панели была пуста, потому что заполнялась из панели обновлений, а та
подтягивается только при открытии. Теперь номер берётся из снапшота и
показывается вместе со временем запуска процесса.

Показывается running_commit — снятый ОДИН РАЗ при старте процесса. Поле commit
читается из манифеста на диске при каждом запросе, поэтому переживший
обновление процесс рапортует им свежий номер при старом поведении; на этом я
уже спотыкался при разборе окон консоли. Снятое при старте значение отвечает на
настоящий вопрос: какой код сейчас в памяти.

678 passed, 2 skipped; ruff чисто; релизный гейт пройден.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 16:04:07 +07:00

248 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Проверки по итогам разбора A57 ревьюером.
Мастер сообщал «Терминал запущен (/usr/bin/x-terminal-emulator) для слота
ag-6», а окна не появлялось. На сервере владельца в это время висел
`[xfce4-terminal] <defunct>` — терминал стартовал и немедленно умирал.
Причин три, и каждая проверяется здесь.
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_discovery_creates_nothing(linux, monkeypatch, tmp_path):
"""Поиск терминала — запрос, а не действие: файлов после него не остаётся."""
monkeypatch.setattr(shutil, "which", _only({"xterm"}))
cmd, err, _ = find_terminal_emulator("ag-6", "/путь/к/запуску", tmp_path)
assert err is None
assert "/путь/к/запуску" in cmd
assert not (tmp_path / ".hermes-agy-login.sh").exists()
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
# ── Пустые слоты появлялись сами ──
def test_asking_for_a_path_creates_nothing(tmp_path, monkeypatch):
"""У владельца накопилось 25 каталогов профилей при единицах заведённых.
Причина: get_profile_dir создавал каталог при каждом обращении, а в коде
зашит список «стандартных» слотов — ag-orch-primary, ag-orch-fallback,
ag-1..ag-20, ag-w1..ag-w10. Любой обход этого списка материализовал их все.
Спросить, где профиль жил бы, и завести его — разные действия.
"""
from antigravity_provider import paths
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
root = tmp_path / "agy_profiles"
for pid in ("ag-orch-primary", "ag-w7", "ag-19", "ag-cold-3"):
paths.get_profile_dir(pid, "antigravity")
assert not root.exists() or not any(root.iterdir()), (
"запрос пути не должен оставлять каталоги на диске"
)
created = paths.get_profile_dir("ag-1", "antigravity", create=True)
assert created.is_dir()
assert sorted(p.name for p in root.iterdir()) == ["ag-1"]
# ── Дисплей ищется не только в окружении ──
def test_display_from_environment_wins(monkeypatch):
from antigravity_provider.agy_subprocess import detect_graphical_session
monkeypatch.setenv("DISPLAY", ":0")
found, checked = detect_graphical_session()
assert found["DISPLAY"] == ":0"
assert any("DISPLAY (задан: :0)" in item for item in checked)
def test_display_is_asked_from_systemd_when_environment_is_empty(monkeypatch):
"""Хаб запускается через nohup: по SSH он остаётся без DISPLAY.
Измерено на сервере владельца: loginctl show-user даёт сеанс c1, а
show-session c1 — Type=x11, Display=:10. Отказываться, не спросив, рано.
"""
from antigravity_provider import agy_subprocess as m
for var in ("DISPLAY", "WAYLAND_DISPLAY", "MIR_SOCKET"):
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("USER", "ochenstarik")
monkeypatch.setattr(m.shutil, "which", lambda n: "/usr/bin/loginctl" if n == "loginctl" else None)
answers = {("show-user", "ochenstarik"): "c1", ("show-session", "c1", "Type"): "x11",
("show-session", "c1", "Display"): ":10"}
class _Res:
def __init__(self, out):
self.stdout, self.returncode = out, 0
def _run(cmd, **kwargs):
if cmd[1] == "show-user":
return _Res(answers[("show-user", cmd[2])])
return _Res(answers[("show-session", cmd[2], cmd[-1])])
monkeypatch.setattr(m.subprocess, "run", _run)
found, checked = m.detect_graphical_session()
assert found == {"DISPLAY": ":10"}
assert any("loginctl сеанс c1" in item for item in checked)
def test_wayland_session_is_reported_as_wayland(monkeypatch):
from antigravity_provider import agy_subprocess as m
for var in ("DISPLAY", "WAYLAND_DISPLAY", "MIR_SOCKET"):
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("USER", "owner")
monkeypatch.setattr(m.shutil, "which", lambda n: "/usr/bin/loginctl" if n == "loginctl" else None)
class _Res:
def __init__(self, out):
self.stdout, self.returncode = out, 0
def _run(cmd, **kwargs):
if cmd[1] == "show-user":
return _Res("2")
return _Res("wayland" if cmd[-1] == "Type" else "wayland-0")
monkeypatch.setattr(m.subprocess, "run", _run)
found, _ = m.detect_graphical_session()
assert found == {"WAYLAND_DISPLAY": "wayland-0"}
def test_no_session_anywhere_lists_what_was_asked(monkeypatch):
from antigravity_provider import agy_subprocess as m
for var in ("DISPLAY", "WAYLAND_DISPLAY", "MIR_SOCKET"):
monkeypatch.delenv(var, raising=False)
monkeypatch.setattr(m.shutil, "which", lambda _n: None)
found, checked = m.detect_graphical_session()
assert found == {}
assert any("loginctl (не найден)" in item for item in checked)