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:
parent
6cb0d7c636
commit
884a632049
6 changed files with 170 additions and 24 deletions
|
|
@ -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.
|
|
@ -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(
|
||||
overrides={
|
||||
"HOME": str(profile_dir),
|
||||
"USERPROFILE": str(profile_dir),
|
||||
"HOMEPATH": str(profile_dir),
|
||||
}
|
||||
)
|
||||
# Дисплей, найденный у 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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue