fix(antigravity): терминал входа не открывался, а мастер сообщал об успехе

Владелец увидел «Терминал запущен (/usr/bin/x-terminal-emulator) для слота
ag-6», но окна не появилось. На сервере в это время висел зомби
[xfce4-terminal] <defunct>: терминал стартовал и немедленно умирал.

Причин три.

x-terminal-emulator на Ubuntu указывает на xfce4-terminal.wrapper, а
xfce4-terminal держит один процесс на сеанс: новый вызов передаёт задание уже
работающему экземпляру и завершается. Нужен --disable-server. Конкретные
эмуляторы теперь пробуются раньше обёртки над альтернативами.

При такой передаче команда выполняется в окружении СТАРОГО экземпляра, и
подменённый HOME не применяется — вход ушёл бы в настоящий домашний каталог
владельца мимо всей изоляции слотов. Теперь терминал запускает сценарий,
который задаёт HOME сам, а не полагается на наследование.

С ключом -e окно закрывается вместе с командой, и причину отказа прочесть
нельзя. Сценарий печатает код возврата agy и ждёт нажатия клавиши. На Windows
по той же причине cmd /k вместо /c.

Отдельно: возврат Popen об открытии окна не говорит ничего, а мастер выдавал
его за успех. Теперь запуск подтверждается тем, что процесс прожил хотя бы
секунду; мгновенное завершение сообщается с кодом возврата.

Тесты подменяли глобальный os.name, а его читает pathlib при выборе класса
пути: на Windows это роняло и проверяемый код, и сам pytest, как только в
ветке для Linux появилась работа с файлами. Заменено явной проверкой системы.

669 passed, 2 skipped; ruff чисто; релизный гейт пройден.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-09-01 15:16:32 +07:00
parent 23e9ac1d6b
commit b309972e49
3 changed files with 253 additions and 19 deletions

View file

@ -18,6 +18,7 @@ import logging
import os
import re
import secrets
import shlex
import shutil
import subprocess
import time
@ -717,6 +718,61 @@ BLOCKED_SECRET_PATTERNS: tuple[str, ...] = (
)
def _is_windows() -> bool:
"""Отдельная проверка системы, чтобы тестам не подменять os.name.
Подмена глобального os.name задевает pathlib: он выбирает по нему
класс пути, и на Windows создание PosixPath падает ломая не только
проверяемый код, но и сам pytest.
"""
return os.name == "nt"
def write_login_helper(profile_dir: Path, agy_exe: str, profile_id: str) -> Path:
"""Создать сценарий, который терминал запустит вместо самой agy.
Три причины, все проверены на сервере владельца.
Окружение задаётся внутри сценария, а не наследуется. Многие эмуляторы
xfce4-terminal, gnome-terminal держат один процесс на сеанс: новый вызов
передаёт задание уже работающему экземпляру и немедленно умирает (в ps
остаётся [xfce4-terminal] <defunct>). Команда при этом выполняется в
окружении СТАРОГО экземпляра, и подменённый HOME не применяется вход
ушёл бы в настоящий домашний каталог владельца мимо изоляции слотов.
Окно не закрывается по завершении agy: с ключом -e терминал исчезает
вместе с командой, и владелец не успевает прочитать причину отказа.
Видно, куда идёт вход: путь к каталогу профиля печатается до запуска.
"""
helper_path = profile_dir / ".hermes-agy-login.sh"
lines = [
"#!/bin/sh",
"# Создан Hermes Hub для входа в слот " + profile_id + ".",
"# Секретов не содержит: только пути.",
"HOME=" + shlex.quote(str(profile_dir)),
"USERPROFILE=" + shlex.quote(str(profile_dir)),
"HOMEPATH=" + shlex.quote(str(profile_dir)),
"export HOME USERPROFILE HOMEPATH",
"cd " + shlex.quote(str(profile_dir)) + " || exit 1",
'echo "Вход Antigravity в слот ' + profile_id + '"',
'echo "Каталог профиля: $HOME"',
'echo',
shlex.quote(agy_exe),
"status=$?",
'echo',
'echo "agy завершился с кодом $status. Окно можно закрыть."',
'printf "Нажмите Enter... "',
"read _ignored",
]
helper_path.write_text(chr(10).join(lines) + chr(10), encoding="utf-8")
try:
os.chmod(helper_path, 0o700)
except OSError:
pass
return helper_path
def find_terminal_emulator(
profile_id: str,
agy_exe: str,
@ -729,7 +785,7 @@ def find_terminal_emulator(
"""
title = f"Antigravity Login ({profile_id})"
if os.name == "nt":
if _is_windows():
checked = ["Windows Terminal (wt.exe)", "cmd.exe", "powershell.exe"]
wt_path = shutil.which("wt.exe") or shutil.which("wt")
if wt_path:
@ -737,7 +793,9 @@ def find_terminal_emulator(
return cmd, None, checked
cmd_path = shutil.which("cmd.exe") or shutil.which("cmd") or "cmd.exe"
cmd = [cmd_path, "/c", "start", title, agy_exe]
# /k вместо /c: иначе окно исчезает вместе с agy и причина отказа
# остаётся непрочитанной.
cmd = [cmd_path, "/c", "start", title, "cmd", "/k", agy_exe]
return cmd, None, checked
# Linux / Unix / macOS
@ -758,18 +816,27 @@ def find_terminal_emulator(
)
return None, err_msg, checked
# Запускаем не саму agy, а сценарий: он сам задаёт HOME и не даёт окну
# закрыться. Подробности — в write_login_helper.
launch = str(write_login_helper(profile_dir, agy_exe, profile_id))
# Конкретные эмуляторы идут раньше x-terminal-emulator: это обёртка над
# альтернативами Debian, лишний слой между нами и настоящей программой.
# У xfce4-terminal обязателен --disable-server, иначе вызов передаётся
# уже работающему экземпляру и наш процесс умирает, не открыв окна, —
# именно это владелец и увидел.
candidates: list[tuple[str, Any]] = [
("x-terminal-emulator", lambda p: [p, "-e", agy_exe]),
("gnome-terminal", lambda p: [p, "--title", title, "--", agy_exe]),
("konsole", lambda p: [p, "-p", f"tabtitle={title}", "-e", agy_exe]),
("xfce4-terminal", lambda p: [p, "--title", title, "-e", agy_exe]),
("tilix", lambda p: [p, "-t", title, "-e", agy_exe]),
("alacritty", lambda p: [p, "-t", title, "-e", agy_exe]),
("kitty", lambda p: [p, "--title", title, agy_exe]),
("terminator", lambda p: [p, "-T", title, "-e", agy_exe]),
("urxvt", lambda p: [p, "-title", title, "-e", agy_exe]),
("foot", lambda p: [p, "--title", title, agy_exe]),
("xterm", lambda p: [p, "-title", title, "-e", agy_exe]),
("xfce4-terminal", lambda p: [p, "--disable-server", "--title", title, "-e", launch]),
("konsole", lambda p: [p, "-p", f"tabtitle={title}", "-e", launch]),
("tilix", lambda p: [p, "-t", title, "-e", launch]),
("alacritty", lambda p: [p, "-t", title, "-e", launch]),
("kitty", lambda p: [p, "--title", title, launch]),
("terminator", lambda p: [p, "-T", title, "-e", launch]),
("urxvt", lambda p: [p, "-title", title, "-e", launch]),
("foot", lambda p: [p, "--title", title, launch]),
("xterm", lambda p: [p, "-title", title, "-e", launch]),
("gnome-terminal", lambda p: [p, "--title", title, "--", launch]),
("x-terminal-emulator", lambda p: [p, "-e", launch]),
]
checked = []
@ -970,7 +1037,7 @@ def start_native_agy_login(
)
try:
subprocess.Popen(
proc = subprocess.Popen(
term_cmd,
env=env,
cwd=str(profile_dir),
@ -978,6 +1045,24 @@ def start_native_agy_login(
stdout=None,
stderr=None,
)
# Popen возвращается сразу и об открытии окна не говорит ничего.
# Владелец видел «Терминал запущен», а окна не было: процесс умирал
# мгновенно, оставляя зомби. Даём ему секунду и смотрим, жив ли он.
time.sleep(1.0)
exit_code = proc.poll()
# Только настоящий ненулевой код считаем отказом: в проверках
# Popen подменяется заглушкой, и её poll() возвращает объект.
if isinstance(exit_code, int) and exit_code != 0:
return False, (
f"Терминал {term_cmd[0]} завершился сразу с кодом {exit_code}, "
f"окно не открылось. Запуск с HOME={profile_dir}"
), {
"profile_id": slot,
"home": str(profile_dir),
"checked_terminals": checked,
"terminal_cmd": term_cmd[0],
"exit_code": exit_code,
}
except Exception as launch_exc:
return False, f"Не удалось запустить терминал ({term_cmd[0]}): {launch_exc}. Запуск с HOME={profile_dir}", {
"profile_id": slot,

View file

@ -16,6 +16,8 @@ from __future__ import annotations
import json
import os
from antigravity_provider import agy_subprocess
import shutil
from pathlib import Path
from unittest.mock import MagicMock
@ -57,7 +59,7 @@ def isolated_hermes_env(tmp_path, monkeypatch):
@pytest.mark.unit
def test_terminal_discovery_linux_found(tmp_path, monkeypatch):
"""P0-1: find_terminal_emulator finds available terminal on Linux and returns correct command."""
monkeypatch.setattr(os, "name", "posix")
monkeypatch.setattr(agy_subprocess, "_is_windows", lambda: False)
monkeypatch.setenv("DISPLAY", ":10.0")
def mock_which(cmd):
@ -72,14 +74,21 @@ def test_terminal_discovery_linux_found(tmp_path, monkeypatch):
assert cmd is not None
assert cmd[0] == "/usr/bin/gnome-terminal"
assert "--" in cmd
assert "/bin/agy" in cmd
# Терминалу передаётся не сама agy, а сценарий: он задаёт HOME сам и
# не даёт окну закрыться. Многие эмуляторы передают вызов уже
# работающему экземпляру, и наследование окружения теряется.
helper = tmp_path / ".hermes-agy-login.sh"
assert str(helper) in cmd
body = helper.read_text(encoding="utf-8")
assert "/bin/agy" in body
assert str(tmp_path) in body
assert any("gnome-terminal (найден: /usr/bin/gnome-terminal)" in item for item in checked)
@pytest.mark.unit
def test_terminal_discovery_linux_missing_honest_error(tmp_path, monkeypatch):
"""P0-1: When no terminal emulator exists on Linux, report honest error listing checked candidates."""
monkeypatch.setattr(os, "name", "posix")
monkeypatch.setattr(agy_subprocess, "_is_windows", lambda: False)
monkeypatch.setenv("DISPLAY", ":10.0")
monkeypatch.setattr(shutil, "which", lambda cmd: None)
@ -97,7 +106,7 @@ 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."""
monkeypatch.setattr(os, "name", "posix")
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)
@ -112,7 +121,7 @@ def test_terminal_discovery_no_display_honest_error(tmp_path, monkeypatch):
@pytest.mark.unit
def test_terminal_discovery_windows(tmp_path, monkeypatch):
"""P0-1: On Windows, find_terminal_emulator selects wt.exe or cmd.exe."""
monkeypatch.setattr(os, "name", "nt")
monkeypatch.setattr(agy_subprocess, "_is_windows", lambda: True)
monkeypatch.setattr(shutil, "which", lambda c: "C:\\Windows\\System32\\wt.exe" if "wt" in c else None)
cmd, err, checked = find_terminal_emulator("ag-1", "C:\\bin\\agy.exe", tmp_path)

View file

@ -0,0 +1,140 @@
"""Проверки по итогам разбора A57 ревьюером.
Мастер сообщал «Терминал запущен (/usr/bin/x-terminal-emulator) для слота
ag-6», а окна не появлялось. На сервере владельца в это время висел
`[xfce4-terminal] <defunct>` терминал стартовал и немедленно умирал.
Причин три, и каждая проверяется здесь.
1. `x-terminal-emulator` на Ubuntu указывает на `xfce4-terminal.wrapper`, а
xfce4-terminal держит один процесс на сеанс: новый вызов передаёт задание
работающему экземпляру и завершается. Нужен `--disable-server`.
2. При такой передаче команда выполняется в окружении СТАРОГО экземпляра, и
подменённый HOME не применяется вход ушёл бы в настоящий домашний
каталог владельца мимо всей изоляции слотов. Поэтому окружение задаёт сам
сценарий, а не наследование.
3. С ключом -e окно закрывается вместе с командой, и причину отказа прочесть
нельзя. Сценарий ждёт нажатия клавиши.
И отдельно: возврат Popen ничего не говорит об открытии окна.
"""
from __future__ import annotations
import shutil
import subprocess
import pytest
from antigravity_provider import agy_subprocess
from antigravity_provider.agy_subprocess import (
find_terminal_emulator,
write_login_helper,
)
@pytest.fixture
def linux(monkeypatch):
monkeypatch.setattr(agy_subprocess, "_is_windows", lambda: False)
monkeypatch.setenv("DISPLAY", ":0")
def _only(available: set[str]):
def _which(name):
return f"/usr/bin/{name}" if name in available else None
return _which
# ── Сценарий входа ──
def test_helper_sets_environment_itself(tmp_path):
helper = write_login_helper(tmp_path, "/usr/local/bin/agy", "ag-6")
body = helper.read_text(encoding="utf-8")
assert "HOME=" in body and str(tmp_path) in body
assert "export HOME" in body
assert "/usr/local/bin/agy" in body
def test_helper_keeps_the_window_open(tmp_path):
body = write_login_helper(tmp_path, "/usr/local/bin/agy", "ag-6").read_text(encoding="utf-8")
assert "read " in body, "иначе окно исчезнет вместе с agy и причина отказа пропадёт"
assert "$status" in body, "код возврата agy должен быть виден владельцу"
def test_helper_contains_no_secrets(tmp_path):
body = write_login_helper(tmp_path, "/usr/local/bin/agy", "ag-6").read_text(encoding="utf-8").lower()
for forbidden in ("access_token", "refresh_token", "api_key", "bearer", "ya29."):
assert forbidden not in body
# ── Выбор эмулятора ──
def test_xfce4_terminal_gets_disable_server(linux, monkeypatch, tmp_path):
monkeypatch.setattr(shutil, "which", _only({"xfce4-terminal", "x-terminal-emulator"}))
cmd, err, _ = find_terminal_emulator("ag-6", "/usr/local/bin/agy", tmp_path)
assert err is None
assert cmd[0] == "/usr/bin/xfce4-terminal"
assert "--disable-server" in cmd, (
"без этого вызов уходит работающему экземпляру и окно не открывается"
)
def test_alternatives_wrapper_is_the_last_resort(linux, monkeypatch, tmp_path):
"""x-terminal-emulator — обёртка над альтернативами, лишний слой."""
monkeypatch.setattr(shutil, "which", _only({"x-terminal-emulator", "xterm"}))
cmd, err, _ = find_terminal_emulator("ag-6", "/usr/local/bin/agy", tmp_path)
assert err is None
assert cmd[0] == "/usr/bin/xterm"
def test_terminal_runs_the_helper_not_agy_directly(linux, monkeypatch, tmp_path):
monkeypatch.setattr(shutil, "which", _only({"xterm"}))
cmd, err, _ = find_terminal_emulator("ag-6", "/usr/local/bin/agy", tmp_path)
assert err is None
assert "/usr/local/bin/agy" not in cmd
assert str(tmp_path / ".hermes-agy-login.sh") in cmd
def test_missing_terminal_lists_what_was_checked(linux, monkeypatch, tmp_path):
monkeypatch.setattr(shutil, "which", _only(set()))
cmd, err, checked = find_terminal_emulator("ag-6", "/usr/local/bin/agy", tmp_path)
assert cmd is None
assert err
assert len(checked) >= 5
assert any("xterm" in item for item in checked)
# ── Запуск подтверждается, а не объявляется ──
def test_immediate_exit_is_reported_as_failure(linux, monkeypatch, tmp_path):
monkeypatch.setattr(shutil, "which", _only({"xterm"}))
monkeypatch.setattr(agy_subprocess.time, "sleep", lambda _s: None)
monkeypatch.setattr(
agy_subprocess, "get_agy_exe", lambda: "/usr/local/bin/agy", raising=False
)
class _DeadProcess:
def __init__(self, *a, **k):
pass
def poll(self):
return 1
monkeypatch.setattr(subprocess, "Popen", _DeadProcess)
ok, msg, data = agy_subprocess.start_native_agy_login(profile_id="ag-6", force=True)
assert ok is False
assert "окно не открылось" in msg
assert data.get("exit_code") == 1