From d7ad3e316573248f4a974edd9720d8195e6cc2f1 Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Tue, 25 Aug 2026 17:54:51 +0700 Subject: [PATCH] feat(updater): in-app updates based on main repo release commits (A27 Pass 1) --- .gitignore | 1 + docs/web-api/CONTRACT.md | 13 +- .../router/action_handler.py | 31 +- src/antigravity_provider/router/web/server.py | 14 + .../router/web/static/app.js | 213 +++++++ .../router/web/static/index.html | 34 ++ .../router/web/static/style.css | 19 + src/antigravity_provider/updater/__init__.py | 14 +- .../updater/update_manager.py | 545 +++++++++++++++--- tests/test_in_app_updates_a27.py | 306 ++++++++++ tests/test_updater.py | 5 +- 11 files changed, 1104 insertions(+), 91 deletions(-) create mode 100644 tests/test_in_app_updates_a27.py diff --git a/.gitignore b/.gitignore index 0db9f8a..db51989 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,4 @@ scratch/ *.obj *.pdb *.ilk +artifacts/ diff --git a/docs/web-api/CONTRACT.md b/docs/web-api/CONTRACT.md index 89aab49..68579fc 100644 --- a/docs/web-api/CONTRACT.md +++ b/docs/web-api/CONTRACT.md @@ -91,12 +91,12 @@ readiness, agents, providers, routing, quotas, metrics, is_stale Имена действий берутся **ровно** из общего слоя `action_handler.py`: ``` -account_details add_account agent_settings assign_role -auto_assign_all check_updates delete_credentials edit_route -oauth open_routing refresh_account refresh_all -refresh_data refresh_models reorder_chain save_chain -save_settings set_main set_model set_orchestrator -test +account_details add_account agent_settings apply_update +assign_role auto_assign_all check_updates delete_credentials +edit_route get_update_status oauth open_routing +refresh_account refresh_all refresh_data refresh_models +reorder_chain save_chain save_settings set_main +set_model set_orchestrator test ``` Ответ: @@ -108,6 +108,7 @@ test **`ok: false` — это `200`, а не `4xx`.** Отказ действия — нормальный результат, а не ошибка протокола. `4xx` остаётся для неизвестного действия и непройденной авторизации. +- **`check_updates`** / **`apply_update`** / **`get_update_status`** (A27): проверка и установка обновлений из самой программы. `check_updates` опрашивает GitHub API релизов основного репозитория `ochenstarik-ui/hermes-hub` и сравнивает установленный коммит со сборкой последнего релиза. `apply_update` загружает установщик/пакет, проверяет sha256 по `checksums.txt` и запускает обновление с сохранением пользовательских данных и настроек. - **`save_chain`** / **`reorder_chain`** / **`edit_route`** (добавлено/расширено в A24): сохранение упорядоченной цепочки профилей для роли маршрутизатора. `data: {"role_id": "", "chain": ["", "", ...]}`. Сохраняет конфигурацию в `router_profiles.yaml` через `AutoAssigner.persist_role_chain`. - **`assign_role`**: назначение профиля на роль. `data: {"profile_id": "", "role_id": "", "is_primary": true|false}`. Выполняется через `AutoAssigner.assign_profile_to_role`. - **`open_routing`** и **`account_details`** в вебе — навигация, состояние держит клиент; сервер на них отвечает `ok: true` без побочных эффектов. diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index 8ad2bdd..940c042 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -580,12 +580,33 @@ class ActionExecutor: return {'ok': ok, 'message': msg} elif action == 'check_updates': + mgr = UpdateManager() if async_runner: - async_runner(lambda: UpdateManager().check_for_updates(), 'CheckUpdates') - return {'ok': True, 'message': 'запущено'} + async_runner(lambda: mgr.check_for_updates(), 'CheckUpdates') + return {'ok': True, 'message': 'Проверка обновлений запущена'} else: - res = UpdateManager().check_for_updates() - return {'ok': True, 'message': 'Успешно', 'data': res} - + res = mgr.check_for_updates() + if res.error: + return {'ok': False, 'message': res.error, 'data': res.to_dict()} + return { + 'ok': True, + 'message': res.message or ('Доступно обновление' if res.update_available else 'Установлена последняя сборка'), + 'data': res.to_dict(), + } + + elif action == 'apply_update': + mgr = UpdateManager() + if async_runner: + async_runner(lambda: mgr.install_latest_update(), 'ApplyUpdate') + return {'ok': True, 'message': 'Установка обновления запущена в фоновом режиме'} + else: + ok, msg = mgr.install_latest_update() + return {'ok': ok, 'message': msg} + + elif action == 'get_update_status': + mgr = UpdateManager() + status = mgr.get_status_dict() + return {'ok': True, 'message': status.get('message') or 'Статус получен', 'data': status} + else: return {'ok': False, 'message': f'Неизвестное действие: {action}', 'unknown': True} diff --git a/src/antigravity_provider/router/web/server.py b/src/antigravity_provider/router/web/server.py index a304b70..a6f269a 100644 --- a/src/antigravity_provider/router/web/server.py +++ b/src/antigravity_provider/router/web/server.py @@ -22,6 +22,8 @@ from antigravity_provider.router.state_store import HubStateStore from antigravity_provider.router.action_handler import ActionExecutor from antigravity_provider.router.router_config import load_router_config +from antigravity_provider.updater.update_manager import get_installed_commit, UpdateManager + logger = logging.getLogger("hermes.router.web") app = FastAPI(title="Hermes Hub Web API", version="1.0.0") @@ -67,6 +69,7 @@ def health_check(): return { "ok": True, "version": __version__, + "commit": get_installed_commit(), # Antigravity и Claude раньше стояли здесь как supported: False с # советом идти в десктоп или пробрасывать порты. Это было неверно: # ProfileOAuthSession.handle_manual_callback_url и @@ -171,6 +174,7 @@ def get_settings(authorized: bool = Depends(get_auth_token)): """Return current server and hub settings without exposing raw auth tokens.""" raw = _web_settings() has_token = bool(raw.get("web_api_token")) + last_check = UpdateManager.get_last_check_result() settings_out: Dict[str, Any] = { "web_api_host": raw.get("web_api_host", "127.0.0.1"), "web_api_port": raw.get("web_api_port", 5800), @@ -180,6 +184,9 @@ def get_settings(authorized: bool = Depends(get_auth_token)): "hermes_home": str(paths.get_hermes_home()), "config_dir": str(paths.get_config_dir()), "log_file": str(paths.get_log_file()), + "installed_commit": get_installed_commit(), + "version": __version__, + "last_update_check": last_check.to_dict() if last_check else None, } for k, v in raw.items(): if k not in settings_out and not any(secret in k.lower() for secret in ['token', 'secret', 'key', 'password', 'jwt']): @@ -274,6 +281,13 @@ def _background_refresh_loop() -> None: except Exception as exc: logger.warning("Quota warm-up failed: %s", exc) + # Initial quiet update check in background + try: + UpdateManager().check_for_updates() + logger.info("Initial update check completed in background") + except Exception as exc: + logger.debug("Initial background update check skipped: %s", exc) + while True: try: HubStateStore.get().refresh(force_scan=False) diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index aa9028f..e97bfd8 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -19,6 +19,7 @@ let authToken = localStorage.getItem('hermes_hub_token') || ''; let cachedEvents = []; let currentSettings = {}; let currentDragState = null; +let latestUpdateInfo = null; const CANONICAL_ROLE_DESCRIPTIONS = { orchestrator: 'Главный оркестратор команды и маршрутизатор запросов', @@ -72,6 +73,7 @@ document.addEventListener('DOMContentLoaded', () => { initSettings(); fetchSnapshot(); startPolling(); + checkUpdates(true); }); // ── NAVIGATION ── @@ -164,6 +166,22 @@ function initEventListeners() { applyTheme(themeSel.value); }); } + + // Updates event listeners + const btnHeaderUpdate = document.getElementById('header-update-badge'); + if (btnHeaderUpdate) { + btnHeaderUpdate.addEventListener('click', () => openUpdateModal()); + } + + const btnCheckUpdates = document.getElementById('btn-check-updates'); + if (btnCheckUpdates) { + btnCheckUpdates.addEventListener('click', () => checkUpdates(false)); + } + + const btnApplyUpdate = document.getElementById('btn-apply-update'); + if (btnApplyUpdate) { + btnApplyUpdate.addEventListener('click', () => applyUpdate()); + } } // ── SNAPSHOT INGESTION & MONOTONIC SEQ ── @@ -1500,6 +1518,11 @@ function populateSettingsForm(s) { if (pathHome) pathHome.textContent = s.hermes_home || '~/.hermes'; if (pathConfig) pathConfig.textContent = s.config_dir || '~/.hermes/config'; if (pathLog) pathLog.textContent = s.log_file || '~/.hermes/logs/hermes-hub.log'; + + if (s.last_update_check && !latestUpdateInfo) { + latestUpdateInfo = s.last_update_check; + } + renderUpdateUI(); } function applyTheme(theme) { @@ -1514,6 +1537,7 @@ function applyTheme(theme) { function renderSettingsView() { loadServerSettings(); + renderUpdateUI(); } async function saveHubServerSettings() { @@ -1547,6 +1571,195 @@ async function saveHubServerSettings() { } } +// ── IN-APP UPDATES (P0-1 — P0-4) ── +async function checkUpdates(silent = false) { + if (!silent) { + showToast('Проверка обновлений...', 'info'); + } + try { + const res = await executeAction('check_updates', {}); + if (res.ok && res.data) { + latestUpdateInfo = res.data; + renderUpdateUI(); + if (!silent) { + if (res.data.update_available) { + const c = res.data.latest_commit ? res.data.latest_commit.slice(0, 7) : (res.data.release_tag || 'new'); + showToast(`Доступно обновление (сборка ${c})`, 'info'); + } else { + showToast(res.data.message || 'Установлена последняя сборка', 'success'); + } + } + } else { + if (res.data) { + latestUpdateInfo = res.data; + renderUpdateUI(); + } + if (!silent) { + showToast(res.message || 'Ошибка проверки обновлений', 'error'); + } + } + } catch (err) { + if (!silent) { + showToast(`Ошибка проверки обновлений: ${err.message}`, 'error'); + } + } +} + +function renderUpdateUI() { + const badge = document.getElementById('header-update-badge'); + 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 : ''); + + if (commitTag) { + commitTag.textContent = installedCommit ? `Сборка: ${installedCommit.slice(0, 7)}` : 'Сборка: —'; + } + + if (badge && badgeText) { + if (latestUpdateInfo && latestUpdateInfo.update_available) { + badge.classList.remove('hidden'); + const c = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : (latestUpdateInfo.release_tag || 'new'); + badgeText.textContent = `Доступно обновление (${c})`; + } else { + badge.classList.add('hidden'); + } + } + + // Populate settings view updates block if elements exist + const updateInfoDesc = document.getElementById('update-installed-info'); + const statusBadge = document.getElementById('update-status-badge'); + const lastCheckedDesc = document.getElementById('update-last-checked-desc'); + const btnApply = document.getElementById('btn-apply-update'); + const detailsBlock = document.getElementById('update-details-block'); + const releaseTitle = document.getElementById('update-release-title'); + const releaseMeta = document.getElementById('update-release-meta'); + const releaseNotes = document.getElementById('update-release-notes'); + + const curVer = (latestUpdateInfo && latestUpdateInfo.current_version) || (currentSettings && currentSettings.version) || '0.1.1'; + const cDisplay = installedCommit ? installedCommit.slice(0, 7) : 'неизвестно'; + if (updateInfoDesc) { + updateInfoDesc.textContent = `Hermes Hub v${curVer} (сборка: ${cDisplay})`; + } + + if (statusBadge) { + if (latestUpdateInfo && latestUpdateInfo.error) { + statusBadge.textContent = 'Ошибка проверки'; + statusBadge.className = 'badge badge-status warning'; + statusBadge.title = latestUpdateInfo.error; + } else if (latestUpdateInfo && latestUpdateInfo.update_available) { + statusBadge.textContent = 'Доступно обновление'; + statusBadge.className = 'badge badge-status warning'; + statusBadge.title = ''; + } else if (latestUpdateInfo && latestUpdateInfo.checked_at > 0) { + statusBadge.textContent = 'Актуально'; + statusBadge.className = 'badge healthy'; + statusBadge.title = ''; + } else { + statusBadge.textContent = 'Не проверялось'; + statusBadge.className = 'badge'; + statusBadge.title = ''; + } + } + + if (lastCheckedDesc) { + if (latestUpdateInfo && latestUpdateInfo.checked_at > 0) { + const tStr = new Date(latestUpdateInfo.checked_at * 1000).toLocaleTimeString('ru-RU'); + const errNote = latestUpdateInfo.error ? ` — Ошибка: ${latestUpdateInfo.error}` : ''; + lastCheckedDesc.textContent = `Последняя проверка: сегодня в ${tStr}${errNote}`; + } else { + lastCheckedDesc.textContent = 'Последняя проверка: еще не выполнялась'; + } + } + + if (btnApply) { + btnApply.disabled = !(latestUpdateInfo && latestUpdateInfo.update_available); + } + + if (detailsBlock && releaseTitle && releaseMeta && releaseNotes) { + if (latestUpdateInfo && latestUpdateInfo.update_available) { + detailsBlock.classList.remove('hidden'); + const latC = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : '—'; + releaseTitle.textContent = `Релиз: ${latestUpdateInfo.release_tag || latestUpdateInfo.latest_version || 'Новая сборка'} (коммит: ${latC})`; + releaseMeta.textContent = latestUpdateInfo.published_at ? `Опубликован: ${latestUpdateInfo.published_at}` : ''; + releaseNotes.textContent = latestUpdateInfo.changelog || latestUpdateInfo.release_notes || 'Описание изменений отсутствует.'; + } else { + detailsBlock.classList.add('hidden'); + } + } +} + +function openUpdateModal() { + if (!latestUpdateInfo) { + checkUpdates(false); + return; + } + + const instC = (latestUpdateInfo.installed_commit && latestUpdateInfo.installed_commit !== 'unknown') + ? latestUpdateInfo.installed_commit.slice(0, 7) + : 'неизвестно'; + const latC = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : (latestUpdateInfo.release_tag || '—'); + + if (elements.modalTitle) elements.modalTitle.textContent = 'Обновление Hermes Hub'; + if (elements.modalBody) { + elements.modalBody.innerHTML = ` +
+
+
+
Текущая сборка:
+
${escapeHtml(instC)}
+
+
+
Новая сборка:
+
${escapeHtml(latC)}
+
+
+
+ Тег: ${escapeHtml(latestUpdateInfo.release_tag || latestUpdateInfo.latest_version || '—')} + ${latestUpdateInfo.published_at ? ` • Дата: ${escapeHtml(latestUpdateInfo.published_at)}` : ''} +
+
Список изменений (Release Notes):
+
+ ${escapeHtml(latestUpdateInfo.changelog || latestUpdateInfo.release_notes || 'Описание изменений отсутствует.')} +
+
+ `; + } + if (elements.modalFooter) { + elements.modalFooter.innerHTML = ` + + + `; + } + showModal(); +} + +async function handleInstallUpdateFromModal() { + const btn = document.getElementById('btn-modal-install-update'); + if (btn) { + btn.disabled = true; + btn.textContent = 'Установка...'; + } + await applyUpdate(); + closeModal(); +} + +async function applyUpdate() { + showToast('Загрузка и запуск обновления...', 'info'); + try { + const res = await executeAction('apply_update', {}); + if (res.ok) { + showToast(res.message || 'Обновление запущено успешно!', 'success'); + } else { + showToast(res.message || 'Ошибка установки обновления', 'error'); + } + } catch (err) { + showToast(`Ошибка установки: ${err.message}`, 'error'); + } +} + // ── MODALS (Account Details, Model Choice, Routing, Wizard) ── function openAccountDetailsModal(profileId) { _openAccountModalProfile = profileId; diff --git a/src/antigravity_provider/router/web/static/index.html b/src/antigravity_provider/router/web/static/index.html index 83d6215..d9cf4a5 100644 --- a/src/antigravity_provider/router/web/static/index.html +++ b/src/antigravity_provider/router/web/static/index.html @@ -57,6 +57,7 @@ Загрузка данных...
Hermes Hub Web v0.1.1
+
@@ -70,6 +71,10 @@ Инициализация... +
+ +
+

Обновление Hermes Hub

+
+
+
Текущая версия и сборка
+
Hermes Hub v0.1.1
+
+
+ Актуально +
+
+
+
+
Проверка обновлений
+
Последняя проверка: еще не выполнялась
+
+
+ + +
+
+ +
+

Системные пути и окружение

diff --git a/src/antigravity_provider/router/web/static/style.css b/src/antigravity_provider/router/web/static/style.css index b590e3f..c5b89cf 100644 --- a/src/antigravity_provider/router/web/static/style.css +++ b/src/antigravity_provider/router/web/static/style.css @@ -264,6 +264,25 @@ body { font-weight: 600; } +.header-update-badge { + display: inline-flex; + align-items: center; + gap: 6px; + background-color: rgba(225, 166, 43, 0.15); + border: 1px solid var(--status-warning); + color: var(--status-warning); + padding: 4px 10px; + border-radius: var(--radius-sm); + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; +} + +.header-update-badge:hover { + background-color: rgba(225, 166, 43, 0.28); +} + .header-actions { display: flex; align-items: center; diff --git a/src/antigravity_provider/updater/__init__.py b/src/antigravity_provider/updater/__init__.py index 689e854..7dd51ca 100644 --- a/src/antigravity_provider/updater/__init__.py +++ b/src/antigravity_provider/updater/__init__.py @@ -1,5 +1,14 @@ """Hermes Hub Auto-Updater Package.""" -from .update_manager import UpdateManager, UpdateManifest, UpdateCheckResult, is_newer_version, compute_sha256 +from .update_manager import ( + UpdateManager, + UpdateManifest, + UpdateCheckResult, + is_newer_version, + compute_sha256, + get_installed_commit, + extract_release_commit, + is_allowed_update_host, +) __all__ = [ "UpdateManager", @@ -7,4 +16,7 @@ __all__ = [ "UpdateCheckResult", "is_newer_version", "compute_sha256", + "get_installed_commit", + "extract_release_commit", + "is_allowed_update_host", ] diff --git a/src/antigravity_provider/updater/update_manager.py b/src/antigravity_provider/updater/update_manager.py index 78ee050..28dbeed 100644 --- a/src/antigravity_provider/updater/update_manager.py +++ b/src/antigravity_provider/updater/update_manager.py @@ -1,10 +1,11 @@ """Hermes Hub — Auto-Update & Integrity Verification Engine. Features: -- Semantic version comparison against release manifest. +- Release commit comparison against main repo releases (ochenstarik-ui/hermes-hub). - SHA-256 package cryptographic hash verification. - Staged download without touching live executable. - Hermetic backup and automatic rollback on corrupt/failing update. +- Non-blocking execution and honest error reporting (rate limits, 404, network errors). - Zero embedded developer PATs (safe public asset feed / signed release manifests). """ from __future__ import annotations @@ -18,8 +19,9 @@ import shutil import subprocess import sys import time +import urllib.error import urllib.request -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Any, Callable, Dict, Optional, Tuple @@ -28,26 +30,149 @@ from antigravity_provider.version import __version__, CHANNEL, MINIMUM_HERMES_VE logger = logging.getLogger("hermes.hub.updater") +DEFAULT_RELEASES_API_URL = "https://api.github.com/repos/ochenstarik-ui/hermes-hub/releases/latest" +DEFAULT_UPDATE_URL = DEFAULT_RELEASES_API_URL + +ALLOWED_UPDATE_HOSTS = { + "api.github.com", + "github.com", + "raw.githubusercontent.com", + "objects.githubusercontent.com", + "github-releases.githubusercontent.com", +} + + +def get_installed_commit() -> str: + """Return currently installed git commit hash, checking deployment manifest, env, and git.""" + # 1. Environment variable override + env_commit = os.environ.get("HERMES_HUB_GIT_COMMIT", "").strip() + if env_commit: + return env_commit + + # 2. Check deployment_manifest.json in repo root or hermes home plugin dir + manifest_candidates = [ + paths.get_repo_root() / "deployment_manifest.json", + paths.get_hermes_home() / "plugins" / "antigravity-provider" / "deployment_manifest.json", + ] + for mf in manifest_candidates: + if mf.is_file(): + try: + data = json.loads(mf.read_text(encoding="utf-8")) + commit = ( + data.get("git_commit") + or data.get("commit") + or data.get("build_commit") + or "" + ).strip() + if commit: + return commit + except Exception as e: + logger.debug("Failed reading %s: %s", mf, e) + + # 3. If running in a git clone, try git rev-parse HEAD + repo_root = paths.get_repo_root() + if (repo_root / ".git").exists() or shutil.which("git"): + try: + res = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=str(repo_root), + capture_output=True, + text=True, + timeout=5, + ) + if res.returncode == 0 and res.stdout.strip(): + return res.stdout.strip() + except Exception as e: + logger.debug("git rev-parse HEAD failed: %s", e) + + return "unknown" + + +def extract_release_commit(release_data: Dict[str, Any]) -> str: + """Extract published git commit hash from GitHub release or manifest dictionary.""" + # 1. Direct field + for key in ("git_commit", "build_commit", "commit"): + val = str(release_data.get(key) or "").strip() + if val and re.fullmatch(r"[0-9a-f]{7,40}", val, re.IGNORECASE): + return val.lower() + + # 2. Check target_commitish if it is a hex SHA + target_commitish = str(release_data.get("target_commitish") or "").strip() + if re.fullmatch(r"[0-9a-f]{7,40}", target_commitish, re.IGNORECASE): + return target_commitish.lower() + + # 3. Look for explicit commit markers in body or name + body = str(release_data.get("body") or "") + name = str(release_data.get("name") or "") + tag = str(release_data.get("tag_name") or "") + + combined_text = f"{name}\n{tag}\n{body}" + + # Specific patterns like "commit: " or "сборка " or "build: " + explicit_match = re.search( + r"(?:commit|build|сборка|rev|sha)[:\s#]+([0-9a-f]{7,40})\b", + combined_text, + re.IGNORECASE, + ) + if explicit_match: + return explicit_match.group(1).lower() + + # Generic SHA search in tag, name, body + for part in (tag, name, body): + m = re.search(r"\b([0-9a-f]{7,40})\b", part, re.IGNORECASE) + if m: + return m.group(1).lower() + + return "" + @dataclass class UpdateManifest: version: str - channel: str - minimum_hermes_version: str - published_at: str - package_url: str - sha256: str + channel: str = "stable" + minimum_hermes_version: str = MINIMUM_HERMES_VERSION + published_at: str = "" + package_url: str = "" + sha256: str = "" release_notes_url: Optional[str] = None changelog: Optional[str] = None + git_commit: Optional[str] = None + assets: Dict[str, str] = field(default_factory=dict) @dataclass class UpdateCheckResult: update_available: bool - current_version: str - latest_version: str + current_version: str = __version__ + latest_version: str = __version__ + installed_commit: str = "unknown" + latest_commit: str = "" + release_tag: str = "" + published_at: str = "" + changelog: Optional[str] = None + release_notes: Optional[str] = None + assets: Dict[str, str] = field(default_factory=dict) manifest: Optional[UpdateManifest] = None error: Optional[str] = None + message: Optional[str] = None + checked_at: float = field(default_factory=time.time) + + def to_dict(self) -> Dict[str, Any]: + return { + "update_available": self.update_available, + "current_version": self.current_version, + "latest_version": self.latest_version, + "installed_commit": self.installed_commit, + "latest_commit": self.latest_commit, + "release_tag": self.release_tag, + "published_at": self.published_at, + "changelog": self.changelog, + "release_notes": self.release_notes, + "assets": self.assets, + "error": self.error, + "message": self.message, + "checked_at": self.checked_at, + } def parse_semver(v: str) -> tuple[int, int, int]: @@ -74,16 +199,6 @@ def compute_sha256(file_path: Path) -> str: return h.hexdigest().lower() -DEFAULT_UPDATE_URL = "https://raw.githubusercontent.com/ochenstarik-ui/hermes-hub-releases/main/update_manifest.json" - -ALLOWED_UPDATE_HOSTS = { - "github.com", - "raw.githubusercontent.com", - "objects.githubusercontent.com", - "github-releases.githubusercontent.com", -} - - def is_allowed_update_host(url: str, allow_dev_local: bool = False) -> bool: """Verify that URL points to an authorized release feed host.""" is_dev = allow_dev_local or os.environ.get("HERMES_HUB_DEV_MODE") == "1" @@ -114,6 +229,9 @@ def is_allowed_update_host(url: str, allow_dev_local: bool = False) -> bool: class UpdateManager: """Manages update checks, package download, hash validation, and updater execution.""" + _last_check_result: Optional[UpdateCheckResult] = None + _last_check_time: float = 0.0 + def __init__(self, manifest_url: Optional[str] = None): self.manifest_url = manifest_url or os.environ.get("HERMES_HUB_UPDATE_URL", DEFAULT_UPDATE_URL) self.updates_dir = paths.get_hermes_home() / "updates" @@ -121,58 +239,233 @@ class UpdateManager: self.backup_dir = self.updates_dir / "backup_prev" self.updates_dir.mkdir(parents=True, exist_ok=True) - def check_for_updates(self, manifest_dict: Optional[Dict[str, Any]] = None) -> UpdateCheckResult: - """Check for updates using either passed manifest (for tests/local) or remote URL.""" - try: - if manifest_dict: - data = manifest_dict - else: - if not is_allowed_update_host(self.manifest_url, allow_dev_local=False): - raise ValueError(f"Недопустимый хост источника обновлений: {self.manifest_url}") + @classmethod + def get_last_check_result(cls) -> Optional[UpdateCheckResult]: + return cls._last_check_result - req = urllib.request.Request( - self.manifest_url, - headers={"User-Agent": f"HermesHub/{__version__} (Windows)"} + def get_status_dict(self) -> Dict[str, Any]: + installed_commit = get_installed_commit() + if self._last_check_result: + d = self._last_check_result.to_dict() + d["installed_commit"] = installed_commit + return d + return { + "update_available": False, + "current_version": __version__, + "latest_version": __version__, + "installed_commit": installed_commit, + "latest_commit": "", + "release_tag": "", + "published_at": "", + "changelog": None, + "release_notes": None, + "assets": {}, + "error": None, + "message": "Проверка обновлений еще не выполнялась", + "checked_at": 0.0, + } + + def check_for_updates( + self, + manifest_dict: Optional[Dict[str, Any]] = None, + release_dict: Optional[Dict[str, Any]] = None, + ) -> UpdateCheckResult: + """Check for updates using either passed manifest/release (for tests/local) or remote URL.""" + installed_commit = get_installed_commit() + data = release_dict if release_dict is not None else manifest_dict + + if not data: + if not is_allowed_update_host(self.manifest_url, allow_dev_local=False): + res = UpdateCheckResult( + update_available=False, + current_version=__version__, + latest_version=__version__, + installed_commit=installed_commit, + error=f"Недопустимый хост источника обновлений: {self.manifest_url}", ) - try: - with urllib.request.urlopen(req, timeout=10) as resp: - data = json.loads(resp.read().decode("utf-8-sig")) - except urllib.error.HTTPError as http_err: - if http_err.code == 404: - return UpdateCheckResult( - update_available=False, - current_version=__version__, - latest_version=__version__, - error="Канал обновлений пока не настроен.", - ) - raise + UpdateManager._last_check_result = res + UpdateManager._last_check_time = time.time() + return res + + req = urllib.request.Request( + self.manifest_url, + headers={ + "User-Agent": f"HermesHub/{__version__}", + "Accept": "application/vnd.github.v3+json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + raw_body = resp.read().decode("utf-8-sig") + data = json.loads(raw_body) + except urllib.error.HTTPError as http_err: + if http_err.code == 403: + err_msg = "Превышен лимит запросов к GitHub API" + elif http_err.code == 404: + err_msg = "Релизы не найдены в репозитории (404 Not Found)" + else: + err_msg = f"Ошибка GitHub API (HTTP {http_err.code}): {http_err.reason}" + logger.warning("Update check HTTP error: %s", err_msg) + res = UpdateCheckResult( + update_available=False, + current_version=__version__, + latest_version=__version__, + installed_commit=installed_commit, + error=err_msg, + ) + UpdateManager._last_check_result = res + UpdateManager._last_check_time = time.time() + return res + except urllib.error.URLError as url_err: + err_msg = f"Сетевая ошибка при проверке обновлений: {url_err.reason}" + logger.warning("Update check network error: %s", err_msg) + res = UpdateCheckResult( + update_available=False, + current_version=__version__, + latest_version=__version__, + installed_commit=installed_commit, + error=err_msg, + ) + UpdateManager._last_check_result = res + UpdateManager._last_check_time = time.time() + return res + except Exception as exc: + err_msg = f"Ошибка проверки обновлений: {exc}" + logger.warning("Update check failed: %s", exc) + res = UpdateCheckResult( + update_available=False, + current_version=__version__, + latest_version=__version__, + installed_commit=installed_commit, + error=err_msg, + ) + UpdateManager._last_check_result = res + UpdateManager._last_check_time = time.time() + return res + + # Process received payload + try: + tag_name = str(data.get("tag_name") or "") + release_name = str(data.get("name") or "") + body = str(data.get("body") or data.get("changelog") or "") + published_at = str(data.get("published_at") or "") + + # Extract assets mapping {name: download_url} + assets_map: Dict[str, str] = {} + raw_assets = data.get("assets", []) + if isinstance(raw_assets, list): + for asset in raw_assets: + if isinstance(asset, dict) and "name" in asset and "browser_download_url" in asset: + assets_map[asset["name"]] = asset["browser_download_url"] + elif isinstance(raw_assets, dict): + assets_map = dict(raw_assets) + + latest_commit = extract_release_commit(data) + + # Package URL and SHA256 from manifest or assets + package_url = data.get("package_url", "") + sha256_hash = data.get("sha256", "").lower() + version_str = str(data.get("version") or tag_name or __version__) manifest = UpdateManifest( - version=data.get("version", "0.0.0"), - channel=data.get("channel", "stable"), + version=version_str, + channel=data.get("channel", CHANNEL), minimum_hermes_version=data.get("minimum_hermes_version", MINIMUM_HERMES_VERSION), - published_at=data.get("published_at", ""), - package_url=data.get("package_url", ""), - sha256=data.get("sha256", "").lower(), - release_notes_url=data.get("release_notes_url"), - changelog=data.get("changelog"), + published_at=published_at, + package_url=package_url, + sha256=sha256_hash, + release_notes_url=data.get("html_url") or data.get("release_notes_url"), + changelog=body, + git_commit=latest_commit, + assets=assets_map, ) - newer = is_newer_version(__version__, manifest.version) - return UpdateCheckResult( - update_available=newer, + # Compare commits + inst_clean = installed_commit.strip().lower() + lat_clean = latest_commit.strip().lower() + + if lat_clean and inst_clean != "unknown": + if inst_clean == lat_clean or inst_clean.startswith(lat_clean) or lat_clean.startswith(inst_clean): + update_available = False + message = "Установлена последняя сборка" + else: + update_available = True + message = f"Доступно обновление (сборка {lat_clean[:7]})" + elif lat_clean and inst_clean == "unknown": + update_available = True + message = f"Доступно обновление (сборка {lat_clean[:7]})" + elif not lat_clean and "version" in data and is_newer_version(__version__, data["version"]): + # Legacy semver fallback + update_available = True + message = f"Доступно обновление {data['version']}" + else: + update_available = False + message = "Установлена последняя сборка" + + res = UpdateCheckResult( + update_available=update_available, current_version=__version__, - latest_version=manifest.version, + latest_version=version_str, + installed_commit=installed_commit, + latest_commit=latest_commit, + release_tag=tag_name or release_name, + published_at=published_at, + changelog=body, + release_notes=body, + assets=assets_map, manifest=manifest, + error=None, + message=message, + checked_at=time.time(), ) + UpdateManager._last_check_result = res + UpdateManager._last_check_time = time.time() + return res + except Exception as exc: - logger.warning("Update check failed: %s", exc) - return UpdateCheckResult( + err_msg = f"Ошибка обработки данных обновления: {exc}" + logger.warning("Update data processing failed: %s", exc) + res = UpdateCheckResult( update_available=False, current_version=__version__, latest_version=__version__, - error=str(exc), + installed_commit=installed_commit, + error=err_msg, ) + UpdateManager._last_check_result = res + UpdateManager._last_check_time = time.time() + return res + + def _download_file( + self, + url: str, + dest_file: Path, + progress_cb: Optional[Callable[[float], None]] = None, + ) -> None: + """Helper to download a file with allowlist check and optional progress callback.""" + if not is_allowed_update_host(url, allow_dev_local=False): + raise ValueError(f"Недопустимый хост пакета обновления: {url}") + + if url.startswith("file://") or Path(url).is_file(): + local_src = Path(url.replace("file://", "")) + shutil.copy2(local_src, dest_file) + if progress_cb: + progress_cb(1.0) + return + + req = urllib.request.Request( + url, + headers={"User-Agent": f"HermesHub/{__version__}"}, + ) + with urllib.request.urlopen(req, timeout=60) as resp: + total_len = int(resp.headers.get("content-length", 0)) + downloaded = 0 + with open(dest_file, "wb") as out_f: + while chunk := resp.read(65536): + out_f.write(chunk) + downloaded += len(chunk) + if progress_cb and total_len > 0: + progress_cb(downloaded / total_len) def download_and_verify( self, @@ -184,30 +477,11 @@ class UpdateManager: dest_file = self.staging_dir / f"hermes-hub-{manifest.version}.zip" try: - if not is_allowed_update_host(manifest.package_url, allow_dev_local=False): - return False, f"Недопустимый хост пакета обновления: {manifest.package_url}", None - - if manifest.package_url.startswith("file://") or Path(manifest.package_url).is_file(): - local_src = Path(manifest.package_url.replace("file://", "")) - shutil.copy2(local_src, dest_file) - else: - req = urllib.request.Request( - manifest.package_url, - headers={"User-Agent": f"HermesHub/{__version__}"} - ) - with urllib.request.urlopen(req, timeout=60) as resp: - total_len = int(resp.headers.get("content-length", 0)) - downloaded = 0 - with open(dest_file, "wb") as out_f: - while chunk := resp.read(32768): - out_f.write(chunk) - downloaded += len(chunk) - if progress_cb and total_len > 0: - progress_cb(downloaded / total_len) + self._download_file(manifest.package_url, dest_file, progress_cb) # Cryptographic SHA-256 Verification calc_hash = compute_sha256(dest_file) - if manifest.sha256 and calc_hash != manifest.sha256: + if manifest.sha256 and calc_hash != manifest.sha256.lower(): dest_file.unlink(missing_ok=True) return False, f"SHA-256 hash mismatch! Expected {manifest.sha256}, got {calc_hash}", None @@ -217,6 +491,123 @@ class UpdateManager: dest_file.unlink(missing_ok=True) return False, f"Ошибка загрузки: {exc}", None + def install_latest_update( + self, + check_result: Optional[UpdateCheckResult] = None, + progress_cb: Optional[Callable[[float], None]] = None, + target_dir: Optional[Path] = None, + ) -> Tuple[bool, str]: + """Download installer or update package, verify checksums, apply and restart.""" + if check_result is None: + check_result = self.check_for_updates() + + if check_result.error: + return False, f"Ошибка проверки обновлений: {check_result.error}" + + if not check_result.update_available: + return False, "Обновление не требуется (установлена последняя сборка)" + + self.staging_dir.mkdir(parents=True, exist_ok=True) + assets = check_result.assets or {} + + # 1. Download checksums.txt if present + checksums_map: Dict[str, str] = {} + if "checksums.txt" in assets: + checksums_url = assets["checksums.txt"] + try: + chk_file = self.staging_dir / "checksums.txt" + self._download_file(checksums_url, chk_file) + for line in chk_file.read_text(encoding="utf-8").splitlines(): + parts = line.strip().split() + if len(parts) >= 2: + sha = parts[0].strip().lower() + fname = parts[1].lstrip("*").strip().lower() + checksums_map[fname] = sha + except Exception as e: + logger.warning("Failed to download or parse checksums.txt: %s", e) + + # 2. Determine target asset to download based on platform + is_win = sys.platform == "win32" + chosen_asset_name: Optional[str] = None + chosen_url: Optional[str] = None + + if is_win: + if "HermesHubSetup.exe" in assets: + chosen_asset_name = "HermesHubSetup.exe" + chosen_url = assets["HermesHubSetup.exe"] + else: + for linux_name in ("hermes-hub-setup.sh", "install-linux.sh"): + if linux_name in assets: + chosen_asset_name = linux_name + chosen_url = assets[linux_name] + break + + # Fallback to any .zip package in assets or manifest package_url + if not chosen_url: + for a_name, a_url in assets.items(): + if a_name.endswith(".zip"): + chosen_asset_name = a_name + chosen_url = a_url + break + + if not chosen_url and check_result.manifest and check_result.manifest.package_url: + chosen_url = check_result.manifest.package_url + chosen_asset_name = Path(chosen_url).name or f"hermes-hub-{check_result.latest_version}.zip" + + if not chosen_url or not chosen_asset_name: + return False, "В релизе не найден подходящий файл обновления для текущей платформы" + + # 3. Download target asset into staging + dest_file = self.staging_dir / chosen_asset_name + try: + self._download_file(chosen_url, dest_file, progress_cb) + except Exception as exc: + dest_file.unlink(missing_ok=True) + return False, f"Ошибка загрузки {chosen_asset_name}: {exc}" + + # 4. SHA-256 Checksum Verification + calc_sha = compute_sha256(dest_file) + expected_sha = checksums_map.get(chosen_asset_name.lower()) + if not expected_sha and check_result.manifest and check_result.manifest.sha256: + expected_sha = check_result.manifest.sha256.lower() + + if expected_sha and calc_sha != expected_sha: + dest_file.unlink(missing_ok=True) + return ( + False, + f"Контрольная сумма SHA-256 не совпала для {chosen_asset_name}! " + f"Ожидалось {expected_sha}, получено {calc_sha}. Установка отменена." + ) + + # 5. Apply update based on file type + if chosen_asset_name.endswith(".zip"): + ok, msg = self.apply_update_sync(dest_file, target_dir=target_dir) + if not ok: + return False, msg + return True, "Обновление успешно установлено" + + elif chosen_asset_name == "HermesHubSetup.exe": + try: + cmd = [str(dest_file), "/silent", "/reinstall"] + creation_flags = 0 + if hasattr(subprocess, "DETACHED_PROCESS") and hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"): + creation_flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP + subprocess.Popen(cmd, creationflags=creation_flags) + return True, "Установщик запущен в фоновом режиме. Hermes Hub будет перезапущен." + except Exception as exc: + return False, f"Не удалось запустить установщик: {exc}" + + elif chosen_asset_name.endswith(".sh"): + try: + os.chmod(dest_file, 0o755) + cmd = ["bash", str(dest_file), "--silent"] + subprocess.Popen(cmd) + return True, "Скрипт установки запущен. Hermes Hub будет перезапущен." + except Exception as exc: + return False, f"Не удалось запустить скрипт установки: {exc}" + + return True, "Файл обновления загружен и проверен" + def apply_update_sync(self, package_zip: Path, target_dir: Optional[Path] = None) -> Tuple[bool, str]: """Apply update package with automatic backup and rollback on failure.""" import zipfile diff --git a/tests/test_in_app_updates_a27.py b/tests/test_in_app_updates_a27.py new file mode 100644 index 0000000..0c2f0ae --- /dev/null +++ b/tests/test_in_app_updates_a27.py @@ -0,0 +1,306 @@ +"""Hermes Hub — Task A27 In-App Updates Test Suite. + +Verifies: +1. check_for_updates when commits match -> update_available = False, message = 'Установлена последняя сборка'. +2. check_for_updates when newer release commit exists -> update_available = True with full metadata. +3. Network error / 403 Rate Limit returns explicit error, never masked as 'no updates'. +4. SHA-256 validation against checksums.txt and rollback/rejection on hash mismatch. +5. Actions 'check_updates', 'apply_update', 'get_update_status' via ActionExecutor. +6. get_installed_commit extraction from manifest, git, and environment variables. +""" +from __future__ import annotations + +import io +import json +import urllib.error +import zipfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from antigravity_provider.router.action_handler import ActionExecutor +from antigravity_provider.router.web.server import app +from antigravity_provider.updater.update_manager import ( + UpdateManager, + UpdateManifest, + UpdateCheckResult, + compute_sha256, + extract_release_commit, + get_installed_commit, + is_allowed_update_host, +) +from antigravity_provider.version import __version__ + + +@pytest.fixture +def client(): + return TestClient(app) + + +# ── TEST 1: Commits Match -> No Update Available ── +@pytest.mark.unit +def test_check_for_updates_commits_match(monkeypatch, tmp_path): + """When installed commit matches latest release commit, update_available must be False.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setenv("HERMES_HUB_GIT_COMMIT", "a1e1db74a3f123456789abcdef0123456789abcd") + + mgr = UpdateManager() + + mock_release = { + "tag_name": "build-2026.08.25", + "target_commitish": "a1e1db74a3f123456789abcdef0123456789abcd", + "name": "Hermes Hub Build 2026.08.25", + "body": "Release commit: a1e1db74a3f123456789abcdef0123456789abcd\nFixes and improvements.", + "published_at": "2026-08-25T10:00:00Z", + "assets": [ + { + "name": "HermesHubSetup.exe", + "browser_download_url": "https://github.com/ochenstarik-ui/hermes-hub/releases/download/build-2026.08.25/HermesHubSetup.exe", + }, + { + "name": "checksums.txt", + "browser_download_url": "https://github.com/ochenstarik-ui/hermes-hub/releases/download/build-2026.08.25/checksums.txt", + }, + ], + } + + res = mgr.check_for_updates(release_dict=mock_release) + assert res.update_available is False + assert res.error is None + assert "последняя" in (res.message or "").lower() + assert res.installed_commit.startswith("a1e1db7") + assert res.latest_commit.startswith("a1e1db7") + + +# ── TEST 2: Newer Commit -> Update Available with Metadata ── +@pytest.mark.unit +def test_check_for_updates_newer_commit_available(monkeypatch, tmp_path): + """When release commit differs from installed commit, update_available must be True.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setenv("HERMES_HUB_GIT_COMMIT", "1111111111111111111111111111111111111111") + + mgr = UpdateManager() + + mock_release = { + "tag_name": "build-2026.08.25", + "target_commitish": "9999999999999999999999999999999999999999", + "name": "Hermes Hub New Release", + "body": "Major speed improvements and new features.\nRelease commit: 9999999", + "published_at": "2026-08-25T12:00:00Z", + "assets": [ + { + "name": "HermesHubSetup.exe", + "browser_download_url": "https://github.com/ochenstarik-ui/hermes-hub/releases/download/v0.1.2/HermesHubSetup.exe", + }, + { + "name": "checksums.txt", + "browser_download_url": "https://github.com/ochenstarik-ui/hermes-hub/releases/download/v0.1.2/checksums.txt", + }, + ], + } + + res = mgr.check_for_updates(release_dict=mock_release) + assert res.update_available is True + assert res.error is None + assert res.latest_commit.startswith("9999999") + assert res.installed_commit.startswith("1111111") + assert res.release_tag == "build-2026.08.25" + assert res.published_at == "2026-08-25T12:00:00Z" + assert "Major speed improvements" in (res.changelog or "") + assert "HermesHubSetup.exe" in res.assets + assert "checksums.txt" in res.assets + + +# ── TEST 3: Network Errors & 403 Rate Limit Are Not Masked ── +@pytest.mark.unit +def test_network_errors_and_rate_limit_handled_honestly(monkeypatch, tmp_path): + """Network errors and HTTP 403 Rate Limit must return error message and NOT be masked as 'no updates'.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + + mgr = UpdateManager(manifest_url="https://api.github.com/repos/ochenstarik-ui/hermes-hub/releases/latest") + + # 1. HTTP 403 Rate Limit + mock_403 = urllib.error.HTTPError( + url=mgr.manifest_url, + code=403, + msg="rate limit exceeded", + hdrs={}, + fp=io.BytesIO(b'{"message":"API rate limit exceeded"}'), + ) + with patch("urllib.request.urlopen", side_effect=mock_403): + res = mgr.check_for_updates() + assert res.update_available is False + assert res.error is not None + assert "лимит" in res.error.lower() or "rate limit" in res.error.lower() + + # 2. General URLError / Network offline + mock_network_err = urllib.error.URLError(reason="Name resolution failure / Offline") + with patch("urllib.request.urlopen", side_effect=mock_network_err): + res = mgr.check_for_updates() + assert res.update_available is False + assert res.error is not None + assert "сетевая ошибка" in res.error.lower() or "offline" in res.error.lower() + + +# ── TEST 4: SHA-256 Checksums.txt Validation & Mismatch Rejection ── +@pytest.mark.unit +def test_checksums_txt_verification_and_mismatch_rejection(monkeypatch, tmp_path): + """install_latest_update downloads checksums.txt, verifies hash, and rejects tampered files.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1") + + # Create dummy package file and compute real sha + pkg_file = tmp_path / "pkg.zip" + with zipfile.ZipFile(pkg_file, "w") as zf: + zf.writestr("test.py", "print('hello')") + real_sha = compute_sha256(pkg_file) + + # Create dummy checksums.txt with WRONG hash + chk_file = tmp_path / "checksums.txt" + chk_file.write_text(f"0000000000000000000000000000000000000000000000000000000000000000 pkg.zip\n", encoding="utf-8") + + mgr = UpdateManager() + + check_res = UpdateCheckResult( + update_available=True, + current_version="0.1.1", + latest_version="0.1.2", + installed_commit="1111111", + latest_commit="2222222", + assets={ + "pkg.zip": f"file://{pkg_file}", + "checksums.txt": f"file://{chk_file}", + }, + ) + + ok, msg = mgr.install_latest_update(check_result=check_res) + assert ok is False + assert "контрольная сумма" in msg.lower() or "mismatch" in msg.lower() or "sha-256" in msg.lower() + + # Now fix checksums.txt with correct hash + chk_file.write_text(f"{real_sha} pkg.zip\n", encoding="utf-8") + app_dir = tmp_path / "app" + app_dir.mkdir(parents=True, exist_ok=True) + (app_dir / "src").mkdir(parents=True, exist_ok=True) + + ok_valid, msg_valid = mgr.install_latest_update(check_result=check_res, target_dir=app_dir) + assert ok_valid is True + + +# ── TEST 5: ActionExecutor check_updates and apply_update ── +@pytest.mark.unit +def test_action_executor_update_actions(monkeypatch, tmp_path): + """ActionExecutor handles check_updates, apply_update, and get_update_status.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setenv("HERMES_HUB_GIT_COMMIT", "feedbeef12345678901234567890123456789012") + + # 1. check_updates synchronous + mock_release = { + "tag_name": "build-2026.08.25", + "target_commitish": "feedbeef12345678901234567890123456789012", + "name": "Hermes Hub Match", + "body": "No changes.", + } + + with patch.object(UpdateManager, "check_for_updates") as mock_chk: + mock_chk.return_value = UpdateCheckResult( + update_available=False, + current_version=__version__, + latest_version=__version__, + installed_commit="feedbeef12345678901234567890123456789012", + latest_commit="feedbeef12345678901234567890123456789012", + message="Установлена последняя сборка", + ) + res = ActionExecutor.execute("check_updates", {}) + assert res["ok"] is True + assert "data" in res + assert res["data"]["update_available"] is False + + # 2. check_updates async + dispatched = [] + def mock_runner(fn, name): + dispatched.append(name) + + res_async = ActionExecutor.execute("check_updates", {}, async_runner=mock_runner) + assert res_async["ok"] is True + assert "CheckUpdates" in dispatched + + # 3. apply_update async + res_apply_async = ActionExecutor.execute("apply_update", {}, async_runner=mock_runner) + assert res_apply_async["ok"] is True + assert "ApplyUpdate" in dispatched + + # 4. get_update_status + res_status = ActionExecutor.execute("get_update_status", {}) + assert res_status["ok"] is True + assert "data" in res_status + assert "installed_commit" in res_status["data"] + + +# ── TEST 6: get_installed_commit Extraction Logic ── +@pytest.mark.unit +def test_get_installed_commit_extraction(monkeypatch, tmp_path): + """get_installed_commit extracts commit from env, deployment_manifest.json, and git.""" + # 1. From environment variable + monkeypatch.setenv("HERMES_HUB_GIT_COMMIT", "abc1234567") + assert get_installed_commit() == "abc1234567" + + # 2. From deployment_manifest.json in hermes home plugins dir + monkeypatch.delenv("HERMES_HUB_GIT_COMMIT", raising=False) + hermes_home = tmp_path / "hermes" + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + plugin_dir = hermes_home / "plugins" / "antigravity-provider" + plugin_dir.mkdir(parents=True, exist_ok=True) + manifest_file = plugin_dir / "deployment_manifest.json" + manifest_file.write_text(json.dumps({"git_commit": "manifest_commit_7890"}), encoding="utf-8") + + assert get_installed_commit() == "manifest_commit_7890" + + # 3. Fallback to unknown when nothing exists + manifest_file.unlink() + # Mock git failure + with patch("subprocess.run", side_effect=Exception("no git")): + with patch("shutil.which", return_value=None): + assert get_installed_commit() == "unknown" + + +# ── TEST 7: extract_release_commit from Various Release Payloads ── +@pytest.mark.unit +def test_extract_release_commit_formats(): + """extract_release_commit extracts hex commit from various GitHub release structures.""" + # Direct field + assert extract_release_commit({"git_commit": "a1e1db7"}) == "a1e1db7" + assert extract_release_commit({"build_commit": "4c2594b"}) == "4c2594b" + + # Target commitish + assert extract_release_commit({"target_commitish": "a1e1db74a3f123456789abcdef0123456789abcd"}) == "a1e1db74a3f123456789abcdef0123456789abcd" + + # In body: "Release commit: 7b7b527..." + assert extract_release_commit({ + "name": "Build 2026.08.25", + "tag_name": "build-2026.08.25", + "body": "Commit: 7b7b527\nChangelog details...", + }) == "7b7b527" + + # In name: "Hermes Hub 0.1.1 (a1e1db7)" + assert extract_release_commit({ + "name": "Hermes Hub 0.1.1 (a1e1db7)", + "tag_name": "build-2026.08.25", + }) == "a1e1db7" + + +# ── TEST 8: GET /api/settings Includes Commit and Update Info ── +@pytest.mark.unit +def test_settings_endpoint_includes_commit_and_update_status(client, monkeypatch, tmp_path): + """GET /api/settings returns installed_commit and last_update_check.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setenv("HERMES_HUB_GIT_COMMIT", "test_commit_123") + + res = client.get("/api/settings") + assert res.status_code == 200 + data = res.json() + assert data.get("installed_commit") == "test_commit_123" + assert "version" in data diff --git a/tests/test_updater.py b/tests/test_updater.py index d9cf3e2..9318dbf 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -61,6 +61,7 @@ def test_host_allowlist_validation(monkeypatch): assert is_allowed_update_host("https://raw.githubusercontent.com/ochenstarik-ui/hermes-hub-releases/main/update_manifest.json") is True assert is_allowed_update_host("https://github.com/ochenstarik-ui/hermes-hub-releases/releases/download/v0.1.1/pkg.zip") is True assert is_allowed_update_host("https://objects.githubusercontent.com/github-production-release-asset/pkg.zip") is True + assert is_allowed_update_host("https://api.github.com/repos/ochenstarik-ui/hermes-hub/releases/latest") is True # 2. Production mode REJECTS local files and arbitrary hosts assert is_allowed_update_host("file:///C:/local/update.zip", allow_dev_local=False) is False @@ -82,7 +83,7 @@ def test_manifest_404_friendly_message(tmp_path, monkeypatch): """Verify that when release feed is not configured (404), a friendly message is returned without crash.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) - mgr = UpdateManager(manifest_url="https://raw.githubusercontent.com/ochenstarik-ui/hermes-hub-releases/main/update_manifest.json") + mgr = UpdateManager(manifest_url="https://api.github.com/repos/ochenstarik-ui/hermes-hub/releases/latest") mock_http_404 = urllib.error.HTTPError( url=mgr.manifest_url, @@ -96,7 +97,7 @@ def test_manifest_404_friendly_message(tmp_path, monkeypatch): res = mgr.check_for_updates() assert res.update_available is False assert res.error is not None - assert "не настроен" in res.error.lower() + assert "404" in res.error.lower() or "не найден" in res.error.lower() or "не настроен" in res.error.lower() @pytest.mark.unit