From 884a632049dcef38d75dcb949a8680b854b29f2b Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Tue, 1 Sep 2026 15:38:27 +0700 Subject: [PATCH] =?UTF-8?q?fix(antigravity):=20=D1=85=D0=B0=D0=B1=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=BB=D1=81=D1=8F=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D1=82=D1=8C=20=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD=D0=B0=D0=BB,=20=D1=81=D1=82=D0=BE=D1=8F=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=80=D0=B0=D0=B1=D0=BE=D1=87=D0=B5=D0=BC=20?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=BB=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Мастер сообщил «Графический дисплей не обнаружен», хотя окно хаба было открыто на рабочем столе владельца. Причина: хаб запускается через 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 --- installer/HermesHubSetup.cs | 2 +- launcher/HermesHub.exe | Bin 6144 -> 6144 bytes launcher/HermesHubWeb.exe | Bin 20992 -> 20992 bytes src/antigravity_provider/agy_subprocess.py | 99 ++++++++++++++++----- tests/test_a57_agy_native_login.py | 13 ++- tests/test_a57_review_terminal_launch.py | 80 +++++++++++++++++ 6 files changed, 170 insertions(+), 24 deletions(-) diff --git a/installer/HermesHubSetup.cs b/installer/HermesHubSetup.cs index 8bb35b1..cb510d7 100644 --- a/installer/HermesHubSetup.cs +++ b/installer/HermesHubSetup.cs @@ -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"; diff --git a/launcher/HermesHub.exe b/launcher/HermesHub.exe index df3111f5020209d48ecac1ca1465628c4d39a011..cf00f72c00f69f302e355e93383347b46dc1b34f 100644 GIT binary patch delta 35 rcmZoLXfT-2!PL;RvFj9%K*m;wmiCHb&!&};tFm7Fh}oRNJBJGZ6%7yK delta 35 rcmZoLXfT-2!SuOvW7jDjfo=5-)%*{adu_k%@ik(H=$*|eymPn!D>D$o diff --git a/launcher/HermesHubWeb.exe b/launcher/HermesHubWeb.exe index b60f2532a5cf495289036144996a355b01c87616..36e7ad136a4b0a55ba2c8048d1f33a5209934ef3 100644 GIT binary patch delta 79 zcmV-V0I>gnqyd1W0g#9SfsC<=`XD4SLN+%;F)%bOH#R~>Ei^DhL@h!%GcqkjGBPzc lHZw*zF-AkPs3FiJ5Cp+zE5CN#K)5pEC3Zi7{IiHfcntp_8yx@u delta 79 zcmV-V0I>gnqyd1W0g#9S^NF#F`XD4mHa9mkF-1l#GC43rEi^DTLoGo=I6*BlMl(4^ lK{GN&I7K+Ks3FiJ5br^E_6;d)K&*;CIr=jA=(C7Lcnm9#94P<* diff --git a/src/antigravity_provider/agy_subprocess.py b/src/antigravity_provider/agy_subprocess.py index 469dafb..111ed9d 100644 --- a/src/antigravity_provider/agy_subprocess.py +++ b/src/antigravity_provider/agy_subprocess.py @@ -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 -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( diff --git a/tests/test_a57_agy_native_login.py b/tests/test_a57_agy_native_login.py index f5208a9..fe9a0c2 100644 --- a/tests/test_a57_agy_native_login.py +++ b/tests/test_a57_agy_native_login.py @@ -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 diff --git a/tests/test_a57_review_terminal_launch.py b/tests/test_a57_review_terminal_launch.py index 06663ac..adcc708 100644 --- a/tests/test_a57_review_terminal_launch.py +++ b/tests/test_a57_review_terminal_launch.py @@ -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)