merge: A27 — обновление из самой программы

Проверено ревьюером исполнением:

  настоящий запрос к релизам      коммит a1e1db7 прочитан из релиза
  установлен тот же коммит        «Установлена последняя сборка»
  короткий SHA против полного     сравнение по префиксу верное
  сеть недоступна                 отказ, а не «обновлений нет»
  предел частоты GitHub API       отказ с внятной причиной
  в интерфейсе                    отметка в шапке, сборка, время проверки
  отказ в интерфейсе              «Ошибка проверки», не «Актуально»

Три дефекта найдены и исправлены ревьюером, подробности в c98806b: функция
была нерабочей в вебе целиком (ответ без данных), контрольная сумма
пропускалась молча при недоступном checksums.txt, перезапуск обещался, но не
выполнялся ни на одной платформе.

Правка .gitignore (artifacts/) законна и безобидна.

450 passed, 2 skipped; ruff чисто.
This commit is contained in:
Hermes Team 2026-08-25 18:32:07 +07:00
commit d5429da63d
11 changed files with 1201 additions and 91 deletions

1
.gitignore vendored
View file

@ -83,3 +83,4 @@ scratch/
*.obj
*.pdb
*.ilk
artifacts/

View file

@ -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": "<role_id>", "chain": ["<pid1>", "<pid2>", ...]}`. Сохраняет конфигурацию в `router_profiles.yaml` через `AutoAssigner.persist_role_chain`.
- **`assign_role`**: назначение профиля на роль. `data: {"profile_id": "<pid>", "role_id": "<role_id>", "is_primary": true|false}`. Выполняется через `AutoAssigner.assign_profile_to_role`.
- **`open_routing`** и **`account_details`** в вебе — навигация, состояние держит клиент; сервер на них отвечает `ok: true` без побочных эффектов.

View file

@ -580,12 +580,38 @@ class ActionExecutor:
return {'ok': ok, 'message': msg}
elif action == 'check_updates':
# Проверка выполняется СИНХРОННО и всегда возвращает данные.
#
# В фоновом режиме действие отвечало «запущено» с пустым data, и
# результат до интерфейса не доходил вообще: проверено запросом,
# ответ был {"ok":true,"message":"запущено","data":{}}. Кнопка
# обновления при этом не могла появиться никогда.
#
# Это один HTTP-запрос к API релизов с таймаутом 10 секунд, а не
# многоминутная установка, поэтому ждать его допустимо. Установка
# (apply_update) по-прежнему уходит в фон.
res = UpdateManager().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: UpdateManager().check_for_updates(), 'CheckUpdates')
return {'ok': True, 'message': 'запущено'}
async_runner(lambda: mgr.install_latest_update(), 'ApplyUpdate')
return {'ok': True, 'message': 'Установка обновления запущена в фоновом режиме'}
else:
res = UpdateManager().check_for_updates()
return {'ok': True, 'message': 'Успешно', 'data': res}
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}

View file

@ -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)

View file

@ -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 = `
<div class="update-modal-body">
<div style="display:flex; justify-content:space-between; margin-bottom:12px; padding:10px; background:var(--surface-muted); border-radius:var(--radius-sm);">
<div>
<div style="font-size:11px; color:var(--text-muted);">Текущая сборка:</div>
<div style="font-weight:600; font-family:var(--font-mono); font-size:13px;">${escapeHtml(instC)}</div>
</div>
<div style="text-align:right;">
<div style="font-size:11px; color:var(--text-muted);">Новая сборка:</div>
<div style="font-weight:600; font-family:var(--font-mono); font-size:13px; color:var(--status-warning);">${escapeHtml(latC)}</div>
</div>
</div>
<div style="margin-bottom:8px; font-size:12px; color:var(--text-muted);">
Тег: <strong>${escapeHtml(latestUpdateInfo.release_tag || latestUpdateInfo.latest_version || '—')}</strong>
${latestUpdateInfo.published_at ? ` &bull; Дата: ${escapeHtml(latestUpdateInfo.published_at)}` : ''}
</div>
<div style="font-weight:600; font-size:12px; margin-bottom:4px;">Список изменений (Release Notes):</div>
<div style="max-height:200px; overflow-y:auto; font-size:12px; line-height:1.4; white-space:pre-wrap; background:var(--surface-muted); padding:10px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); font-family:var(--font-mono);">
${escapeHtml(latestUpdateInfo.changelog || latestUpdateInfo.release_notes || 'Описание изменений отсутствует.')}
</div>
</div>
`;
}
if (elements.modalFooter) {
elements.modalFooter.innerHTML = `
<button class="btn btn-secondary" onclick="closeModal()">Закрыть</button>
<button class="btn btn-primary" id="btn-modal-install-update" onclick="handleInstallUpdateFromModal()">Установить обновление</button>
`;
}
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;

View file

@ -57,6 +57,7 @@
<span class="source-text" id="source-text">Загрузка данных...</span>
</div>
<div class="version-tag" id="version-tag">Hermes Hub Web v0.1.1</div>
<div class="commit-tag" id="commit-tag" style="font-size:11px; color:var(--text-muted); font-family:var(--font-mono); margin-top:2px;"></div>
</div>
</aside>
@ -70,6 +71,10 @@
<span class="status-dot"></span>
<span id="header-readiness-text">Инициализация...</span>
</span>
<button class="header-update-badge hidden" id="header-update-badge" title="Нажмите, чтобы посмотреть детали обновления">
<span class="status-dot warning"></span>
<span id="header-update-text">Доступно обновление</span>
</button>
</div>
<div class="header-actions">
<button class="btn btn-secondary" id="btn-refresh-all" title="Обновить квоты всех профилей">
@ -379,6 +384,35 @@
</div>
</div>
<!-- Hub Updates -->
<div class="settings-card" style="margin-top:16px;" id="settings-updates-card">
<h2 class="settings-group-title">Обновление Hermes Hub</h2>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Текущая версия и сборка</div>
<div class="setting-desc" id="update-installed-info">Hermes Hub v0.1.1</div>
</div>
<div class="setting-control" style="display:flex; align-items:center; gap:8px;">
<span id="update-status-badge" class="badge healthy">Актуально</span>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Проверка обновлений</div>
<div class="setting-desc" id="update-last-checked-desc">Последняя проверка: еще не выполнялась</div>
</div>
<div class="setting-control" style="display:flex; gap:8px;">
<button class="btn btn-secondary btn-sm" id="btn-check-updates">Проверить обновления</button>
<button class="btn btn-primary btn-sm" id="btn-apply-update" disabled>Установить обновление</button>
</div>
</div>
<div id="update-details-block" class="update-details-box hidden" style="margin-top:12px; padding:12px; background:var(--surface-muted); border-radius:var(--radius-sm); border:1px solid var(--border-subtle);">
<div id="update-release-title" style="font-weight:600; font-size:13px; margin-bottom:4px;"></div>
<div id="update-release-meta" style="font-size:12px; color:var(--text-muted); margin-bottom:8px;"></div>
<div id="update-release-notes" style="font-size:12px; max-height:160px; overflow-y:auto; white-space:pre-wrap; background:var(--surface-card); padding:8px; border-radius:4px; border:1px solid var(--border-subtle); font-family:var(--font-mono);"></div>
</div>
</div>
<!-- Read-Only System Paths -->
<div class="settings-card" style="margin-top:16px;">
<h2 class="settings-group-title">Системные пути и окружение</h2>

View file

@ -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;

View file

@ -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",
]

View file

@ -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
@ -16,10 +17,12 @@ import os
import re
import shutil
import subprocess
import threading
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 +31,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: <sha>" or "сборка <sha>" or "build: <sha>"
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 +200,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 +230,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 +240,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 +478,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 +492,204 @@ 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()
# Отсутствие суммы — не разрешение. Раньше при недоступном checksums.txt
# expected_sha оставался пустым, проверка молча пропускалась и скачанный
# файл всё равно запускался. Здесь запускается загруженный из сети
# исполняемый код, поэтому непроверенный файл не запускаем вовсе.
if not expected_sha:
dest_file.unlink(missing_ok=True)
return (
False,
f"Не удалось получить контрольную сумму для {chosen_asset_name}: "
"в релизе нет checksums.txt или файл не скачался. "
"Установка отменена — непроверенный файл не запускается."
)
if 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
proc = subprocess.Popen(cmd, creationflags=creation_flags)
try:
rc = proc.wait(timeout=600)
except subprocess.TimeoutExpired:
return False, "Установщик не завершился за 10 минут. Проверьте состояние вручную."
if rc != 0:
return False, f"Установщик завершился с кодом {rc}. Обновление не применено."
ok_r, msg_r = self.schedule_restart()
if not ok_r:
return True, f"Обновление установлено. {msg_r}"
return True, "Обновление установлено, Hermes Hub перезапускается."
except Exception as exc:
return False, f"Не удалось запустить установщик: {exc}"
elif chosen_asset_name.endswith(".sh"):
try:
os.chmod(dest_file, 0o755)
# Ждём завершения: без этого перезапуск начался бы прямо во
# время распаковки, а владелец получил бы обещание перезапуска
# при неизвестном исходе установки.
res_i = subprocess.run(
["bash", str(dest_file)],
capture_output=True, text=True, timeout=600,
)
if res_i.returncode != 0:
tail = (res_i.stderr or res_i.stdout or "").strip().splitlines()[-3:]
return False, "Установка не удалась: " + " / ".join(tail)
ok_r, msg_r = self.schedule_restart()
if not ok_r:
return True, f"Обновление установлено. {msg_r}"
return True, "Обновление установлено, Hermes Hub перезапускается."
except Exception as exc:
return False, f"Не удалось запустить скрипт установки: {exc}"
return True, "Файл обновления загружен и проверен"
def schedule_restart(self, delay_sec: float = 3.0) -> Tuple[bool, str]:
"""Перезапустить веб-хаб после установки обновления.
Ни install-linux.sh, ни виндовый установщик в тихом режиме приложение не
поднимают, а сообщение обещало перезапуск. Владелец оставался со старым
процессом, продолжавшим отдавать старый код, и делал вывод, что
обновление не сработало.
Порядок именно такой: сначала отсоединённый помощник, потом выход
текущего процесса. Лаунчер считает хаб работающим, если порт отвечает,
поэтому поднимать новый, не освободив порт, бесполезно.
"""
home = paths.get_hermes_home()
if sys.platform == "win32":
launcher = home / "HermesHubWeb.exe"
else:
launcher = Path.home() / ".local" / "bin" / "hermes-hub-web"
if not launcher.exists():
return False, f"Лаунчер не найден: {launcher}. Запустите Hermes Hub вручную."
try:
if sys.platform == "win32":
flags = 0
if hasattr(subprocess, "DETACHED_PROCESS") and hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
subprocess.Popen(
["cmd", "/c", f"timeout /t {int(delay_sec)} >nul & \"{launcher}\""],
creationflags=flags,
)
else:
subprocess.Popen(
["nohup", "sh", "-c", f"sleep {delay_sec}; exec '{launcher}'"],
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception as exc:
return False, f"Не удалось запланировать перезапуск: {exc}. Запустите Hermes Hub вручную."
def _exit_soon() -> None:
time.sleep(max(0.5, delay_sec - 1.5))
os._exit(0)
threading.Thread(target=_exit_soon, daemon=True, name="hub-restart").start()
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

View file

@ -0,0 +1,316 @@
"""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_runner всегда, фоновая ветка
# отвечала «Проверка обновлений запущена» без data, и результат до
# интерфейса не доходил вовсе. Проверено запросом: кнопка обновления не
# могла появиться никогда. Проверка — один HTTP-запрос с таймаутом 10
# секунд, ждать её допустимо; в фон уходит только установка.
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" not in dispatched, "проверка обновлений не должна уходить в фон без данных"
# Значение здесь не проверяем: вызов вне заглушки и ходит в сеть по-настоящему.
# Важно ровно одно — данные пришли, а не пустой ответ «запущено».
assert res_async.get("data"), "ответ без данных: интерфейс не узнает о наличии обновления"
# 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

View file

@ -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