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-коммита. Раньше здесь
|
// Подставляется сборщиком из фактического 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.
|
|
@ -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, обязан попасть в окружение терминала:
|
||||||
|
# в окружении самого хаба его может не быть, если хаб запущен по SSH или
|
||||||
|
# службой, а окно всё равно должно открыться на рабочем столе владельца.
|
||||||
overrides = {
|
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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue