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:
parent
23e9ac1d6b
commit
b309972e49
3 changed files with 253 additions and 19 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
140
tests/test_a57_review_terminal_launch.py
Normal file
140
tests/test_a57_review_terminal_launch.py
Normal 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
|
||||
Loading…
Reference in a new issue