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')
// Первым источником — снапшот работающего сервера: он приходит всегда, а
// панель обновлений заполняется только при её открытии. На 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 : '');
: (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()