fix(antigravity): терминал не открывался из-за подменённого HOME; видно работающую сборку

Мастер честно сообщил: «Терминал /usr/bin/xfce4-terminal завершился сразу с
кодом 1, окно не открылось» — новая проверка запуска сработала.

Причина в моей же правке. Терминал запускался с HOME, подменённым на каталог
профиля, а клиенты X11 берут ключ авторизации из ~/.Xauthority: в
agy_profiles/ag-5 такого файла нет и быть не может, поэтому подключиться к
дисплею терминал не мог. Подменять HOME терминалу и не требуется — это делает
сценарий входа, уже внутри окна, перед самым запуском agy.

Заодно устранено побочное действие в запросе: сценарий входа создавался внутри
функции ПОИСКА терминала. Теперь его готовит start_native_agy_login, а поиск
только отвечает на вопрос и ничего не создаёт.

Владелец не мог понять, обновился ли хаб: на Linux строка сборки в боковой
панели была пуста, потому что заполнялась из панели обновлений, а та
подтягивается только при открытии. Теперь номер берётся из снапшота и
показывается вместе со временем запуска процесса.

Показывается running_commit — снятый ОДИН РАЗ при старте процесса. Поле commit
читается из манифеста на диске при каждом запросе, поэтому переживший
обновление процесс рапортует им свежий номер при старом поведении; на этом я
уже спотыкался при разборе окон консоли. Снятое при старте значение отвечает на
настоящий вопрос: какой код сейчас в памяти.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-09-01 16:04:07 +07:00
parent 884a632049
commit 28f35f863f
9 changed files with 131 additions and 35 deletions

View file

@ -18,7 +18,7 @@ namespace HermesHubSetup
// Подставляется сборщиком из фактического git-коммита. Раньше здесь
// жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из
// какого кода собран установщик.
public const string BuildCommit = "6cb0d7c";
public const string BuildCommit = "884a632";
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.

View file

@ -870,9 +870,10 @@ 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))
# Что запускаем, решает вызывающий: поиск терминала не должен ничего
# создавать. Нативный вход передаёт сюда путь сценария входа, а не саму
# agy — см. write_login_helper.
launch = agy_exe
# Конкретные эмуляторы идут раньше x-terminal-emulator: это обёртка над
# альтернативами Debian, лишний слой между нами и настоящей программой.
@ -1074,7 +1075,10 @@ def start_native_agy_login(
except Exception as exc:
return False, str(exc), {"profile_id": slot, "home": str(profile_dir)}
term_cmd, err_msg, checked = find_terminal_emulator(slot, agy_exe, profile_dir)
# Терминал запускает сценарий, а не agy напрямую: сценарий сам задаёт HOME
# и не даёт окну закрыться вместе с командой.
launch_script = str(write_login_helper(profile_dir, agy_exe, slot))
term_cmd, err_msg, checked = find_terminal_emulator(slot, launch_script, profile_dir)
if err_msg or not term_cmd:
return False, err_msg or "Терминал не найден", {
"profile_id": slot,
@ -1082,18 +1086,16 @@ def start_native_agy_login(
"checked_terminals": checked,
}
# Дисплей, найденный у systemd, обязан попасть в окружение терминала:
# в окружении самого хаба его может не быть, если хаб запущен по SSH или
# службой, а окно всё равно должно открыться на рабочем столе владельца.
overrides = {
"HOME": str(profile_dir),
"USERPROFILE": str(profile_dir),
"HOMEPATH": str(profile_dir),
}
# HOME терминалу НЕ подменяем. Клиенты X11 берут ключ авторизации из
# ~/.Xauthority, и с подменённым HOME его там нет: xfce4-terminal не мог
# подключиться к дисплею и выходил с кодом 1, не открыв окна. Изоляцию
# обеспечивает сценарий входа — он задаёт HOME сам, уже внутри терминала,
# непосредственно перед запуском agy.
#
# Дисплей, найденный у systemd, наоборот, обязан попасть в окружение: в
# окружении самого хаба его может не быть, если хаб запущен по SSH.
session_env, _checked = detect_graphical_session()
overrides.update(session_env)
env = build_safe_subprocess_env(overrides=overrides)
env = build_safe_subprocess_env(overrides=dict(session_env))
try:
proc = subprocess.Popen(

View file

@ -99,6 +99,19 @@ def get_auth_token(x_hub_token: str = Header(None)) -> bool:
raise HTTPException(status_code=401, detail="Invalid X-Hub-Token")
return True
# Коммит и время запуска СНИМАЮТСЯ ОДИН РАЗ, при старте процесса.
#
# get_installed_commit() читает манифест с диска при каждом вызове, поэтому
# переживший обновление процесс бодро рапортует свежий коммит: владелец видит
# новый номер и старое поведение. Отличить сборки по такому полю нельзя.
#
# Снятое при старте значение отвечает на настоящий вопрос: какой код сейчас
# в памяти. Вместе со временем запуска этого достаточно, чтобы понять, дошло
# ли обновление до работы, а не только до файлов.
RUNNING_COMMIT: str = get_installed_commit()
PROCESS_STARTED_AT: float = time.time()
@app.get("/api/health")
def health_check():
from ..account_probe_service import AccountProbeService
@ -108,6 +121,8 @@ def health_check():
"ok": True,
"version": __version__,
"commit": get_installed_commit(),
"running_commit": RUNNING_COMMIT,
"started_at": PROCESS_STARTED_AT,
# Antigravity и Claude раньше стояли здесь как supported: False с
# советом идти в десктоп или пробрасывать порты. Это было неверно:
# ProfileOAuthSession.handle_manual_callback_url и
@ -236,6 +251,8 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)):
snap_dict["version"] = __version__
snap_dict["commit"] = get_installed_commit()
snap_dict["running_commit"] = RUNNING_COMMIT
snap_dict["started_at"] = PROCESS_STARTED_AT
snap_dict["system_paths"] = {
"hermes_home": str(paths.get_hermes_home()),
"config_dir": str(paths.get_config_dir()),

View file

@ -2426,12 +2426,30 @@ function renderUpdateUI() {
const badgeText = document.getElementById('header-update-text');
const commitTag = document.getElementById('commit-tag');
const installedCommit = (latestUpdateInfo && latestUpdateInfo.installed_commit && latestUpdateInfo.installed_commit !== 'unknown')
? latestUpdateInfo.installed_commit
: (currentSettings && currentSettings.installed_commit ? currentSettings.installed_commit : '');
// Первым источником — снапшот работающего сервера: он приходит всегда, а
// панель обновлений заполняется только при её открытии. На Linux строка
// сборки поэтому оставалась пустой, и понять, дошло ли обновление, было
// нельзя.
//
// Берём running_commit — коммит, снятый при СТАРТЕ процесса. Поле commit
// читается с диска при каждом запросе, и переживший обновление процесс
// рапортует им свежий номер при старом поведении.
const runningCommit = (currentSnapshot && currentSnapshot.running_commit) || '';
const installedCommit = runningCommit
|| (latestUpdateInfo && latestUpdateInfo.installed_commit && latestUpdateInfo.installed_commit !== 'unknown'
? latestUpdateInfo.installed_commit
: (currentSettings && currentSettings.installed_commit ? currentSettings.installed_commit : ''));
if (commitTag) {
commitTag.textContent = installedCommit ? `Сборка: ${installedCommit.slice(0, 7)}` : 'Сборка: —';
if (!installedCommit) {
commitTag.textContent = 'Сборка: Н/Д (сервер не передал номер)';
} else {
const startedAt = currentSnapshot && currentSnapshot.started_at;
const since = startedAt
? ` · запущен ${new Date(startedAt * 1000).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}`
: '';
commitTag.textContent = `Сборка: ${installedCommit.slice(0, 7)}${since}`;
}
}
if (badge && badgeText) {

View file

@ -74,14 +74,9 @@ def test_terminal_discovery_linux_found(tmp_path, monkeypatch):
assert cmd is not None
assert cmd[0] == "/usr/bin/gnome-terminal"
assert "--" 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
# Поиск терминала подставляет то, что ему передали, и ничего не создаёт.
# Сценарий входа готовит start_native_agy_login.
assert "/bin/agy" in cmd
assert any("gnome-terminal (найден: /usr/bin/gnome-terminal)" in item for item in checked)
@ -167,13 +162,20 @@ def test_start_native_agy_login_creates_isolated_home(tmp_path, monkeypatch):
assert "session_id" in data
assert pdir.is_dir()
# Verify subprocess called with isolated HOME
# Изоляция обеспечивается сценарием входа, а не окружением терминала.
# HOME терминалу подменять нельзя: клиенты X11 берут ключ авторизации из
# ~/.Xauthority, и с подменённым HOME его там нет — xfce4-terminal выходил
# с кодом 1, не открыв окна. Сценарий задаёт HOME сам, уже внутри окна.
mock_popen.assert_called_once()
call_kwargs = mock_popen.call_args[1]
assert call_kwargs["env"]["HOME"] == str(pdir)
assert call_kwargs["env"]["USERPROFILE"] == str(pdir)
assert call_kwargs["env"].get("HOME") != str(pdir)
assert call_kwargs["cwd"] == str(pdir)
helper = pdir / ".hermes-agy-login.sh"
assert helper.is_file(), "сценарий входа должен быть создан"
body = helper.read_text(encoding="utf-8")
assert str(pdir) in body and "export HOME" in body
@pytest.mark.unit
def test_start_native_agy_login_occupied_slot_requires_confirmation(tmp_path, monkeypatch):

View file

@ -94,14 +94,15 @@ def test_alternatives_wrapper_is_the_last_resort(linux, monkeypatch, tmp_path):
assert cmd[0] == "/usr/bin/xterm"
def test_terminal_runs_the_helper_not_agy_directly(linux, monkeypatch, tmp_path):
def test_terminal_discovery_creates_nothing(linux, monkeypatch, tmp_path):
"""Поиск терминала — запрос, а не действие: файлов после него не остаётся."""
monkeypatch.setattr(shutil, "which", _only({"xterm"}))
cmd, err, _ = find_terminal_emulator("ag-6", "/usr/local/bin/agy", tmp_path)
cmd, err, _ = find_terminal_emulator("ag-6", "/путь/к/запуску", tmp_path)
assert err is None
assert "/usr/local/bin/agy" not in cmd
assert str(tmp_path / ".hermes-agy-login.sh") in cmd
assert "/путь/к/запуску" in cmd
assert not (tmp_path / ".hermes-agy-login.sh").exists()
def test_missing_terminal_lists_what_was_checked(linux, monkeypatch, tmp_path):

View file

@ -0,0 +1,56 @@
"""Владелец должен видеть, какой код сейчас работает.
На Linux строка сборки в боковой панели оставалась пустой: она заполнялась из
панели обновлений, а та подтягивается только при открытии. Понять, дошло ли
обновление, было нельзя.
Глубже: поле commit читается из манифеста на диске при каждом запросе, поэтому
переживший обновление процесс рапортует свежий номер при старом поведении.
Отличить сборки по нему невозможно это уже подводило при разборе окон
консоли. running_commit снимается один раз, при старте процесса, и отвечает на
настоящий вопрос: какой код в памяти.
"""
from __future__ import annotations
import time
from fastapi.testclient import TestClient
from antigravity_provider.router.web.server import (
PROCESS_STARTED_AT,
RUNNING_COMMIT,
app,
)
def test_health_reports_the_running_build():
payload = TestClient(app).get("/api/health").json()
assert payload["running_commit"] == RUNNING_COMMIT
assert payload["started_at"] == PROCESS_STARTED_AT
def test_snapshot_reports_the_running_build():
snap = TestClient(app).get("/api/snapshot").json()
assert snap["running_commit"] == RUNNING_COMMIT
assert snap["started_at"] == PROCESS_STARTED_AT
def test_running_commit_is_taken_once_and_does_not_follow_the_disk(monkeypatch):
"""Подмена манифеста на диске не должна менять номер работающей сборки."""
from antigravity_provider.router.web import server
monkeypatch.setattr(server, "get_installed_commit", lambda: "deadbee")
payload = TestClient(app).get("/api/health").json()
assert payload["commit"] == "deadbee", "поле commit отражает диск"
assert payload["running_commit"] == RUNNING_COMMIT, (
"а running_commit — то, что реально запущено"
)
assert payload["running_commit"] != "deadbee"
def test_start_time_is_plausible():
assert PROCESS_STARTED_AT > 0
assert PROCESS_STARTED_AT <= time.time()