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-коммита. Раньше здесь
// жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из
// какого кода собран установщик.
public const string BuildCommit = "b309972";
public const string BuildCommit = "6cb0d7c";
public const string MIN_HERMES_VERSION = "0.20.0";
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:
"""Отдельная проверка системы, чтобы тестам не подменять os.name.
@ -799,20 +861,12 @@ def find_terminal_emulator(
return cmd, None, checked
# Linux / Unix / macOS
display = os.environ.get("DISPLAY", "").strip()
wayland = os.environ.get("WAYLAND_DISPLAY", "").strip()
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 'не задан'})",
]
session_env, checked = detect_graphical_session()
if not session_env:
err_msg = (
"Графический дисплей не обнаружен (переменные DISPLAY/WAYLAND_DISPLAY не заданы). "
"Для входа на сервере без графического интерфейса используйте вход по ссылке через браузер "
"либо запустите Hub в сессии с графическим дисплеем."
"Графический сеанс не обнаружен: ни в окружении хаба, ни у systemd. "
"Для входа на машине без графического интерфейса используйте вход по ссылке "
"через браузер."
)
return None, err_msg, checked
@ -1028,13 +1082,18 @@ def start_native_agy_login(
"checked_terminals": checked,
}
env = build_safe_subprocess_env(
# Дисплей, найденный у systemd, обязан попасть в окружение терминала:
# в окружении самого хаба его может не быть, если хаб запущен по SSH или
# службой, а окно всё равно должно открыться на рабочем столе владельца.
overrides = {
"HOME": str(profile_dir),
"USERPROFILE": 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:
proc = subprocess.Popen(

View file

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