merge: A27 — обновление из самой программы
Проверено ревьюером исполнением: настоящий запрос к релизам коммитa1e1db7прочитан из релиза установлен тот же коммит «Установлена последняя сборка» короткий SHA против полного сравнение по префиксу верное сеть недоступна отказ, а не «обновлений нет» предел частоты GitHub API отказ с внятной причиной в интерфейсе отметка в шапке, сборка, время проверки отказ в интерфейсе «Ошибка проверки», не «Актуально» Три дефекта найдены и исправлены ревьюером, подробности вc98806b: функция была нерабочей в вебе целиком (ответ без данных), контрольная сумма пропускалась молча при недоступном checksums.txt, перезапуск обещался, но не выполнялся ни на одной платформе. Правка .gitignore (artifacts/) законна и безобидна. 450 passed, 2 skipped; ruff чисто.
This commit is contained in:
commit
d5429da63d
11 changed files with 1201 additions and 91 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -83,3 +83,4 @@ scratch/
|
|||
*.obj
|
||||
*.pdb
|
||||
*.ilk
|
||||
artifacts/
|
||||
|
|
|
|||
|
|
@ -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` без побочных эффектов.
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ? ` • Дата: ${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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
316
tests/test_in_app_updates_a27.py
Normal file
316
tests/test_in_app_updates_a27.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue