diff --git a/installer/HermesHubSetup.cs b/installer/HermesHubSetup.cs index cb510d7..191f042 100644 --- a/installer/HermesHubSetup.cs +++ b/installer/HermesHubSetup.cs @@ -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"; diff --git a/launcher/HermesHub.exe b/launcher/HermesHub.exe index cf00f72..9a04c56 100644 Binary files a/launcher/HermesHub.exe and b/launcher/HermesHub.exe differ diff --git a/launcher/HermesHubWeb.exe b/launcher/HermesHubWeb.exe index 36e7ad1..8faa9a9 100644 Binary files a/launcher/HermesHubWeb.exe and b/launcher/HermesHubWeb.exe differ diff --git a/src/antigravity_provider/agy_subprocess.py b/src/antigravity_provider/agy_subprocess.py index 111ed9d..8d3352f 100644 --- a/src/antigravity_provider/agy_subprocess.py +++ b/src/antigravity_provider/agy_subprocess.py @@ -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( diff --git a/src/antigravity_provider/router/web/server.py b/src/antigravity_provider/router/web/server.py index 872d397..e413e59 100644 --- a/src/antigravity_provider/router/web/server.py +++ b/src/antigravity_provider/router/web/server.py @@ -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()), diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index cd31098..26f7af3 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -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) { diff --git a/tests/test_a57_agy_native_login.py b/tests/test_a57_agy_native_login.py index fe9a0c2..d7f9f31 100644 --- a/tests/test_a57_agy_native_login.py +++ b/tests/test_a57_agy_native_login.py @@ -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): diff --git a/tests/test_a57_review_terminal_launch.py b/tests/test_a57_review_terminal_launch.py index adcc708..3b169cb 100644 --- a/tests/test_a57_review_terminal_launch.py +++ b/tests/test_a57_review_terminal_launch.py @@ -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): diff --git a/tests/test_running_build_is_visible.py b/tests/test_running_build_is_visible.py new file mode 100644 index 0000000..8017365 --- /dev/null +++ b/tests/test_running_build_is_visible.py @@ -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()