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>
This commit is contained in:
Hermes Team 2026-09-01 15:38:27 +07:00
parent 6cb0d7c636
commit 884a632049
6 changed files with 170 additions and 24 deletions

View file

@ -18,7 +18,7 @@ namespace HermesHubSetup
// Подставляется сборщиком из фактического git-коммита. Раньше здесь // Подставляется сборщиком из фактического git-коммита. Раньше здесь
// жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из // жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из
// какого кода собран установщик. // какого кода собран установщик.
public const string BuildCommit = "b309972"; public const string BuildCommit = "6cb0d7c";
public const string MIN_HERMES_VERSION = "0.20.0"; public const string MIN_HERMES_VERSION = "0.20.0";
public const string MAX_TESTED_HERMES = "0.20.4"; public const string MAX_TESTED_HERMES = "0.20.4";

Binary file not shown.

Binary file not shown.

View file

@ -718,6 +718,68 @@ BLOCKED_SECRET_PATTERNS: tuple[str, ...] = (
) )
def detect_graphical_session() -> tuple[dict[str, str], list[str]]:
"""Найти графический сеанс владельца. Вернуть (переменные, что проверено).
Хаб запускается через nohup и наследует окружение той оболочки, из которой
его запустили. Запуск по SSH или из службы оставляет процесс без DISPLAY
и хаб отказывался открыть терминал, стоя при этом на рабочем столе.
Наследование не единственный источник. systemd знает про сеанс: на сервере
владельца `loginctl show-user <user> -p Display` даёт c1, а
`loginctl show-session c1` Type=x11, Display=:10, Active=yes. Спросить у
системы честнее, чем сдаться.
XAUTHORITY не выставляем: клиенты X11 по умолчанию берут ~/.Xauthority
того же пользователя, а хаб работает под ним же.
"""
checked: list[str] = []
found: dict[str, str] = {}
for var in ("DISPLAY", "WAYLAND_DISPLAY", "MIR_SOCKET"):
value = os.environ.get(var, "").strip()
checked.append(f"{var} ({'задан: ' + value if value else 'не задан'})")
if value:
found[var] = value
if found:
return found, checked
loginctl = shutil.which("loginctl")
if not loginctl:
checked.append("loginctl (не найден)")
return {}, checked
def _ask(args: list[str]) -> str:
try:
res = subprocess.run(
[loginctl, *args],
capture_output=True, text=True, timeout=5,
encoding="utf-8", errors="replace",
stdin=subprocess.DEVNULL,
)
return res.stdout.strip() if res.returncode == 0 else ""
except Exception:
return ""
user = os.environ.get("USER") or os.environ.get("LOGNAME") or ""
session = _ask(["show-user", user, "--value", "-p", "Display"]) if user else ""
if not session:
checked.append(f"loginctl show-user {user or '<пользователь неизвестен>'} (сеанс не назван)")
return {}, checked
stype = _ask(["show-session", session, "--value", "-p", "Type"])
display = _ask(["show-session", session, "--value", "-p", "Display"])
checked.append(f"loginctl сеанс {session} (тип: {stype or 'Н'}, дисплей: {display or 'Н'})")
if stype == "wayland" and display:
found["WAYLAND_DISPLAY"] = display
elif display:
found["DISPLAY"] = display
return found, checked
def _is_windows() -> bool: def _is_windows() -> bool:
"""Отдельная проверка системы, чтобы тестам не подменять os.name. """Отдельная проверка системы, чтобы тестам не подменять os.name.
@ -799,20 +861,12 @@ def find_terminal_emulator(
return cmd, None, checked return cmd, None, checked
# Linux / Unix / macOS # Linux / Unix / macOS
display = os.environ.get("DISPLAY", "").strip() session_env, checked = detect_graphical_session()
wayland = os.environ.get("WAYLAND_DISPLAY", "").strip() if not session_env:
mir = os.environ.get("MIR_SOCKET", "").strip()
if not display and not wayland and not mir:
checked = [
f"DISPLAY ({'задан: ' + os.environ['DISPLAY'] if 'DISPLAY' in os.environ else 'не задан'})",
f"WAYLAND_DISPLAY ({'задан: ' + os.environ['WAYLAND_DISPLAY'] if 'WAYLAND_DISPLAY' in os.environ else 'не задан'})",
f"MIR_SOCKET ({'задан: ' + os.environ['MIR_SOCKET'] if 'MIR_SOCKET' in os.environ else 'не задан'})",
]
err_msg = ( err_msg = (
"Графический дисплей не обнаружен (переменные DISPLAY/WAYLAND_DISPLAY не заданы). " "Графический сеанс не обнаружен: ни в окружении хаба, ни у systemd. "
"Для входа на сервере без графического интерфейса используйте вход по ссылке через браузер " "Для входа на машине без графического интерфейса используйте вход по ссылке "
"либо запустите Hub в сессии с графическим дисплеем." "через браузер."
) )
return None, err_msg, checked return None, err_msg, checked
@ -1028,13 +1082,18 @@ def start_native_agy_login(
"checked_terminals": checked, "checked_terminals": checked,
} }
env = build_safe_subprocess_env( # Дисплей, найденный у systemd, обязан попасть в окружение терминала:
overrides={ # в окружении самого хаба его может не быть, если хаб запущен по SSH или
# службой, а окно всё равно должно открыться на рабочем столе владельца.
overrides = {
"HOME": str(profile_dir), "HOME": str(profile_dir),
"USERPROFILE": str(profile_dir), "USERPROFILE": str(profile_dir),
"HOMEPATH": str(profile_dir), "HOMEPATH": str(profile_dir),
} }
) session_env, _checked = detect_graphical_session()
overrides.update(session_env)
env = build_safe_subprocess_env(overrides=overrides)
try: try:
proc = subprocess.Popen( proc = subprocess.Popen(

View file

@ -105,17 +105,24 @@ def test_terminal_discovery_linux_missing_honest_error(tmp_path, monkeypatch):
@pytest.mark.unit @pytest.mark.unit
def test_terminal_discovery_no_display_honest_error(tmp_path, monkeypatch): 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.""" """P0-1: сеанса нет ни в окружении, ни у systemd — отказ с перечнем проверенного.
Пустое окружение само по себе больше не приговор: хаб запускается через
nohup и по SSH остаётся без DISPLAY, стоя при этом на рабочем столе. Отказ
правомерен, только когда и systemd графического сеанса не знает.
"""
monkeypatch.setattr(agy_subprocess, "_is_windows", lambda: False) monkeypatch.setattr(agy_subprocess, "_is_windows", lambda: False)
monkeypatch.delenv("DISPLAY", raising=False) monkeypatch.delenv("DISPLAY", raising=False)
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
monkeypatch.delenv("MIR_SOCKET", raising=False) monkeypatch.delenv("MIR_SOCKET", raising=False)
monkeypatch.setattr(agy_subprocess.shutil, "which", lambda _n: None)
cmd, err, checked = find_terminal_emulator("ag-1", "/bin/agy", tmp_path) cmd, err, checked = find_terminal_emulator("ag-1", "/bin/agy", tmp_path)
assert cmd is None assert cmd is None
assert err is not None assert err is not None
assert "Графический дисплей не обнаружен" in err assert "Графический сеанс не обнаружен" in err
assert "DISPLAY/WAYLAND_DISPLAY не заданы" in err assert any("DISPLAY (не задан)" in item for item in checked)
assert any("loginctl" in item for item in checked)
@pytest.mark.unit @pytest.mark.unit

View file

@ -165,3 +165,83 @@ def test_asking_for_a_path_creates_nothing(tmp_path, monkeypatch):
created = paths.get_profile_dir("ag-1", "antigravity", create=True) created = paths.get_profile_dir("ag-1", "antigravity", create=True)
assert created.is_dir() assert created.is_dir()
assert sorted(p.name for p in root.iterdir()) == ["ag-1"] 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)