hermes-hub/tests/test_a57_review_terminal_launch.py
Hermes Team 884a632049 fix(antigravity): хаб отказывался открыть терминал, стоя на рабочем столе
Мастер сообщил «Графический дисплей не обнаружен», хотя окно хаба было открыто
на рабочем столе владельца. Причина: хаб запускается через nohup и наследует
окружение той оболочки, из которой его запустили. Запуск по SSH или службой
оставляет процесс без DISPLAY.

Наследование не единственный источник. Измерено на сервере владельца:
loginctl show-user ochenstarik -p Display даёт c1, а show-session c1 —
Type=x11, Display=:10, Active=yes. Спросить у системы честнее, чем сдаться.

Теперь дисплей ищется сначала в окружении, затем у systemd, и найденное
значение передаётся в окружение терминала — иначе окно не открылось бы даже
при верном обнаружении. Отказ остаётся правомерным, только когда графического
сеанса не знает и systemd; в сообщении перечисляется всё проверенное, включая
сам loginctl.

XAUTHORITY не выставляем: клиенты X11 по умолчанию берут ~/.Xauthority того же
пользователя, а хаб работает под ним же.

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

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

247 lines
9.7 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_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
# ── Пустые слоты появлялись сами ──
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)