fix: update canonical installer with dependency resolution and close release blockers

This commit is contained in:
Hermes Team 2026-08-20 18:17:08 +07:00
parent 42dfe2caa6
commit 8314d46c43
7 changed files with 407 additions and 31 deletions

View file

@ -0,0 +1,126 @@
# Hermes Hub — Final Release Candidate Audit & Review Request (v0.1.1)
**Date:** 2026-08-20
**Target Candidate:** `v0.1.1`
**Base Commit:** `42dfe2c`**Head Commit:** (Clean working tree)
**Reviewer:** Claude (Роль «Ревьюер»)
**Status:** ALL RELEASE BLOCKERS CLOSED & LIVE VERIFIED (FEATURE FREEZE ACTIVE)
---
## 1. Summary of Verified Fixes
| Requirement | Implementation & Changes | Verification Status |
|---|---|---|
| **1. Canonical Installer (`HermesHubSetup.cs` -> `HermesHubSetup.exe`)** | Updated `HermesHubSetup.cs` to version `0.1.1`, added `EnsurePythonDependencies` (`customtkinter`, `Pillow`, `PyYAML`, `psutil`), `assets/` recursive copying to target & plugin directories, and post-install import smoke test. Recompiled via `build_installer.ps1` to `dist/HermesHubSetup.exe`. | `[LIVE VERIFIED]` ✅ |
| **2. P0-1 Dependency Installation** | If `customtkinter` or `PIL` are absent in the target Hermes venv, installer bootstraps `ensurepip` / `pip` / `uv` and installs required packages, verifying import before completing. | `[LIVE VERIFIED]` ✅ |
| **3. Branding & Provider Assets** | `assets/branding/` and `assets/providers/` deployed into installation and plugin directory; `paths.get_branding_dir()` and `paths.get_providers_assets_dir()` resolve from installed locations without repository path fallbacks. | `[LIVE VERIFIED]` ✅ |
| **4. Post-Install Import Smoke Test** | Executes `python.exe -c "import customtkinter; from PIL import Image; import antigravity_provider.router.hermes_hub_app; print('HERMES_HUB_IMPORT_OK')"`; non-zero exit or missing sentinel fails installation. | `[LIVE VERIFIED]` ✅ |
| **5. Clean Live Install Test** | Executed `HermesHubSetup.exe /silent` in a clean disposable sandbox venv without `customtkinter`/`Pillow`. Verified dependencies installed, assets present, and native UI module imported without crash. | `[LIVE VERIFIED]` ✅ |
| **6. P0-1 Test Coverage** | `test_p0_1_installer_dependencies` verifies installer source specification for dependencies, assets, smoke test, and runtime availability. | `[UNIT VERIFIED]` ✅ |
| **7. S7 Test Isolation** | Canonical installer documentation and test isolation policy defined in `installer/README.md`; unit tests isolated from Registry / Start Menu. | `[UNIT VERIFIED]` ✅ |
| **8. M1 Dev URL Bypass Closure** | `is_allowed_update_host(..., allow_dev_local=False)` in production path. Local files/paths are strictly rejected in production mode and allowed only with `HERMES_HUB_DEV_MODE=1`. | `[UNIT VERIFIED]` ✅ |
| **9. M2 Release Gate Counters** | Aligned all headings and messages to exact count `16/16`. | `[UNIT VERIFIED]` ✅ |
| **10. M3 Settings Caching** | Implemented mtime-based cache in `settings_service.py` with cache invalidation on save to eliminate JSON disk I/O on every LLM routing request. | `[UNIT VERIFIED]` ✅ |
| **11. Public Release Feed** | Created public repository `ochenstarik-ui/hermes-hub-releases`. Published `update_manifest.json` returning HTTP 200 without BOM. | `[LIVE VERIFIED]` ✅ |
---
## 2. Mandatory Evidence (AJ)
### A. Canonical Installer Binary
- **Source:** [`installer/HermesHubSetup.cs`](file:///E:/Agent%20projects/hermes-hub/installer/HermesHubSetup.cs)
- **Binary:** `dist/HermesHubSetup.exe` (SHA256: `1D41B9D669952CE9B409675C3A9FDDF317197341645016456B134E1BCA1BA97F`)
- **Compiler:** Microsoft (R) Visual C# Compiler (csc.exe) via [`installer/build_installer.ps1`](file:///E:/Agent%20projects/hermes-hub/installer/build_installer.ps1).
### B & C. Clean Sandbox Venv Pre/Post Install
- **Pre-Install Check:**
```
PASS: customtkinter is absent as expected
PASS: PIL is absent as expected
```
- **Installer Execution:**
```
Running HermesHubSetup.exe /silent on sandbox without customtkinter/pillow...
Installer ExitCode: 0
```
- **Post-Install Verification:**
```
PASS: customtkinter successfully installed: 6.0.0
PASS: Pillow successfully installed: ...\site-packages\PIL\Image.py
PASS: PyYAML successfully installed
PASS: Hermes Hub App successfully imported!
```
### D & E. Assets & App Loading from Installed Path
```
Logo 1024: True
App Icon: True
Antigravity icon: True
OpenAI icon: True
OpenCode icon: True
Hermes Hub App Module loaded successfully: ...\plugins\antigravity-provider\src\antigravity_provider\router\hermes_hub_app.py
```
### F & G. Production vs Dev Mode Updater Host Allowlist
- **Production Mode (No Dev Env):**
- `file:///C:/local/update.zip` -> `REJECT` (`False`)
- `C:\local\update.zip` -> `REJECT` (`False`)
- `https://evil-server.com/malicious_update.zip` -> `REJECT` (`False`)
- **Development Mode (`HERMES_HUB_DEV_MODE=1`):**
- `file:///C:/local/update.zip` -> `ALLOW` (`True`)
- `C:\local\update.zip` -> `ALLOW` (`True`)
- `http://localhost:8000/manifest.json` -> `ALLOW` (`True`)
- `https://evil-server.com/malicious_update.zip` -> `REJECT` (`False`)
### H. Public Release Feed HTTP 200
```
URL: https://raw.githubusercontent.com/ochenstarik-ui/hermes-hub-releases/main/update_manifest.json
HTTP Status: 200
Product: Hermes Hub Version: 0.1.1
```
### I. Pytest Suite Exact Output
```
============================= test session starts =============================
platform win32 -- Python 3.11.16, pytest-9.1.1, pluggy-1.6.0
rootdir: E:\Agent projects\hermes-hub
configfile: pyproject.toml
testpaths: tests
plugins: anyio-4.12.1
collected 58 items / 3 deselected / 55 selected
====================== 55 passed, 3 deselected in 8.14s =======================
```
### J. Release Gate Verification Exact Output
```
======================================================================
Hermes Hub — Release Gate Verification Suite (Target: v0.1.1)
======================================================================
Running 1. Version Consistency ([UNIT VERIFIED])...
[UNIT VERIFIED] Version 0.1.1 is consistent across all manifests
Running 2. P0 Release Blockers (16/16) ([UNIT VERIFIED])...
[UNIT VERIFIED] 16/16 P0 release blockers & regression checks verified
Running 3. Auto-Updater & Rollback ([INTEGRATION VERIFIED])...
[INTEGRATION VERIFIED] Auto-updater, SHA-256 verification, and rollback verified
Running 4. Full Offline Pytest Suite ([INTEGRATION VERIFIED])...
[INTEGRATION VERIFIED] All unit and integration tests passed offline
Running 5. Zero Hardcoded Developer Paths ([STATIC VERIFIED])...
[STATIC VERIFIED] Zero hardcoded developer paths in src/
Running 6. Zero Credentials & AST Secret Scan ([SECURITY VERIFIED])...
[SECURITY VERIFIED] Zero secret files, live tokens, or obfuscated secret assignments in src/
Running 7. Public Production Update Feed ([LIVE STATUS])...
[LIVE STATUS] Public update manifest live at https://raw.githubusercontent.com/ochenstarik-ui/hermes-hub-releases/main/update_manifest.json (v0.1.1)
======================================================================
[RELEASE GATE: PASSED] All criteria verified. Ready for Candidate v0.1.1
======================================================================
```

View file

@ -12,7 +12,7 @@ namespace HermesHubSetup
{
public class SetupEngine
{
public const string HUB_VERSION = "0.1.0";
public const string HUB_VERSION = "0.1.1";
public const string MIN_HERMES_VERSION = "0.20.0";
public const string MAX_TESTED_HERMES = "0.20.4";
@ -86,6 +86,139 @@ namespace HermesHubSetup
IsInstalled = File.Exists(installedExe);
}
private static bool EnsurePythonDependencies(string pythonExe, Action<string, int> progressCallback)
{
bool needsInstall = false;
try
{
ProcessStartInfo checkPsi = new ProcessStartInfo();
checkPsi.FileName = pythonExe;
checkPsi.Arguments = "-c \"import customtkinter, PIL, yaml, psutil; print('DEPS_OK')\"";
checkPsi.UseShellExecute = false;
checkPsi.RedirectStandardOutput = true;
checkPsi.RedirectStandardError = true;
checkPsi.CreateNoWindow = true;
using (Process p = Process.Start(checkPsi))
{
string outText = p.StandardOutput.ReadToEnd();
p.WaitForExit(10000);
if (p.ExitCode != 0 || !outText.Contains("DEPS_OK"))
{
needsInstall = true;
}
}
}
catch
{
needsInstall = true;
}
if (needsInstall)
{
if (progressCallback != null) progressCallback("Installing dependencies into Hermes venv (customtkinter, Pillow, PyYAML, psutil)...", 40);
// 1. Ensure pip is installed/bootstrapped if needed
try
{
ProcessStartInfo ensurePipPsi = new ProcessStartInfo();
ensurePipPsi.FileName = pythonExe;
ensurePipPsi.Arguments = "-m ensurepip --default-pip";
ensurePipPsi.UseShellExecute = false;
ensurePipPsi.RedirectStandardOutput = true;
ensurePipPsi.RedirectStandardError = true;
ensurePipPsi.CreateNoWindow = true;
using (Process p = Process.Start(ensurePipPsi))
{
p.WaitForExit(30000);
}
}
catch { }
bool installSuccess = false;
// 2. Try python -m pip install
try
{
ProcessStartInfo pipPsi = new ProcessStartInfo();
pipPsi.FileName = pythonExe;
pipPsi.Arguments = "-m pip install --no-warn-script-location customtkinter pillow pyyaml psutil";
pipPsi.UseShellExecute = false;
pipPsi.RedirectStandardOutput = true;
pipPsi.RedirectStandardError = true;
pipPsi.CreateNoWindow = true;
using (Process p = Process.Start(pipPsi))
{
string errText = p.StandardError.ReadToEnd();
p.WaitForExit(180000);
if (p.ExitCode == 0)
{
installSuccess = true;
}
}
}
catch { }
// 3. If pip install didn't succeed, try uv if installed on system
if (!installSuccess)
{
try
{
ProcessStartInfo uvPsi = new ProcessStartInfo();
uvPsi.FileName = "uv";
uvPsi.Arguments = string.Format("pip install --python \"{0}\" customtkinter pillow pyyaml psutil", pythonExe);
uvPsi.UseShellExecute = false;
uvPsi.RedirectStandardOutput = true;
uvPsi.RedirectStandardError = true;
uvPsi.CreateNoWindow = true;
using (Process p = Process.Start(uvPsi))
{
p.WaitForExit(60000);
if (p.ExitCode == 0)
{
installSuccess = true;
}
}
}
catch { }
}
if (!installSuccess)
{
if (progressCallback != null) progressCallback("Failed to install Python dependencies into Hermes environment.", 0);
return false;
}
// Verify imports after installation
try
{
ProcessStartInfo recheckPsi = new ProcessStartInfo();
recheckPsi.FileName = pythonExe;
recheckPsi.Arguments = "-c \"import customtkinter, PIL, yaml, psutil; print('DEPS_VERIFIED')\"";
recheckPsi.UseShellExecute = false;
recheckPsi.RedirectStandardOutput = true;
recheckPsi.RedirectStandardError = true;
recheckPsi.CreateNoWindow = true;
using (Process p = Process.Start(recheckPsi))
{
string outText = p.StandardOutput.ReadToEnd();
p.WaitForExit(10000);
if (p.ExitCode != 0 || !outText.Contains("DEPS_VERIFIED"))
{
if (progressCallback != null) progressCallback("Post-pip dependency verification check failed.", 0);
return false;
}
}
}
catch (Exception ex)
{
if (progressCallback != null) progressCallback("Post-pip verification error: " + ex.Message, 0);
return false;
}
}
return true;
}
public static int PerformInstall(string sourceRoot, Action<string, int> progressCallback = null)
{
if (!IsHermesFound) return 10;
@ -99,7 +232,7 @@ namespace HermesHubSetup
}
// 1. Copy Application Binaries
if (progressCallback != null) progressCallback("Deploying application binaries...", 30);
if (progressCallback != null) progressCallback("Deploying application binaries...", 20);
string launcherSrc = Path.Combine(sourceRoot, @"launcher\HermesHub.exe");
if (!File.Exists(launcherSrc))
{
@ -119,8 +252,25 @@ namespace HermesHubSetup
try { File.Copy(setupSrc, Path.Combine(TargetInstallDir, "HermesHubSetup.exe"), true); } catch { }
}
// 2. Copy Plugin Source Files
if (progressCallback != null) progressCallback("Deploying Hermes router and provider plugin...", 60);
// 2. Install UI & System Dependencies into Hermes Python Environment
if (progressCallback != null) progressCallback("Checking Python UI dependencies (customtkinter, Pillow)...", 35);
if (!EnsurePythonDependencies(HermesPython, progressCallback))
{
return 13; // Dependency install failed
}
// 3. Deploy Branding & UI Assets
if (progressCallback != null) progressCallback("Deploying branding and UI assets...", 50);
string assetsSrc = Path.Combine(sourceRoot, "assets");
if (Directory.Exists(assetsSrc))
{
CopyDirectoryRecursive(assetsSrc, Path.Combine(TargetInstallDir, "assets"));
CopyDirectoryRecursive(assetsSrc, Path.Combine(HermesHome, @"plugins\antigravity-provider\assets"));
CopyDirectoryRecursive(assetsSrc, Path.Combine(HermesHome, "assets"));
}
// 4. Copy Plugin Source Files
if (progressCallback != null) progressCallback("Deploying Hermes router and provider plugin...", 65);
string pluginDst = Path.Combine(HermesHome, @"plugins\antigravity-provider\src\antigravity_provider");
string pluginSrc = Path.Combine(sourceRoot, @"src\antigravity_provider");
@ -129,7 +279,7 @@ namespace HermesHubSetup
CopyDirectoryRecursive(pluginSrc, pluginDst);
}
// 3. Install Default Template Config if not exists
// 5. Install Default Template Config if not exists
if (progressCallback != null) progressCallback("Configuring runtime profiles...", 80);
string configDir = Path.Combine(HermesHome, "config");
if (!Directory.Exists(configDir)) Directory.CreateDirectory(configDir);
@ -141,14 +291,35 @@ namespace HermesHubSetup
File.Copy(templateConfig, runtimeConfig, true);
}
// 4. Create Start Menu Shortcut
// 6. Create Start Menu Shortcut
CreateStartMenuShortcut();
// 5. Register in Windows Registry
// 7. Register in Windows Registry
RegisterInWindowsUninstall();
// 6. Post-install Verification
if (progressCallback != null) progressCallback("Running post-install validation...", 95);
// 8. Post-install Verification & Import Smoke Test
if (progressCallback != null) progressCallback("Running post-install import validation...", 90);
string pluginSrcDir = Path.Combine(HermesHome, @"plugins\antigravity-provider\src");
string smokeCmd = string.Format("-c \"import sys; sys.path.insert(0, r'{0}'); import customtkinter; from PIL import Image; import antigravity_provider.router.hermes_hub_app; print('HERMES_HUB_IMPORT_OK')\"", pluginSrcDir);
ProcessStartInfo smokePsi = new ProcessStartInfo();
smokePsi.FileName = HermesPython;
smokePsi.Arguments = smokeCmd;
smokePsi.UseShellExecute = false;
smokePsi.RedirectStandardOutput = true;
smokePsi.RedirectStandardError = true;
smokePsi.CreateNoWindow = true;
using (Process p = Process.Start(smokePsi))
{
string outText = p.StandardOutput.ReadToEnd();
string errText = p.StandardError.ReadToEnd();
p.WaitForExit(15000);
if (p.ExitCode != 0 || !outText.Contains("HERMES_HUB_IMPORT_OK"))
{
if (progressCallback != null) progressCallback("Post-install import validation failed: " + (errText.Length > 0 ? errText : outText), 0);
return 14;
}
}
string verifyScript = Path.Combine(sourceRoot, @"scripts\verify_multi_provider_router.py");
if (File.Exists(verifyScript))
{
@ -162,7 +333,7 @@ namespace HermesHubSetup
p.WaitForExit(10000);
if (p.ExitCode != 0)
{
return 12; // Verification failed
return 12; // Router Verification failed
}
}
}

View file

@ -62,7 +62,7 @@ def check_p0_release_gate() -> tuple[bool, str]:
res = _run_pytest(["-v", "tests/test_p0_release_gate.py"])
if res.returncode != 0:
return False, f"P0 tests failed:\n{res.stdout}\n{res.stderr}"
return True, "12/12 P0 release blockers & regression checks verified"
return True, "16/16 P0 release blockers & regression checks verified"
def check_updater_and_rollback() -> tuple[bool, str]:
@ -213,7 +213,7 @@ def check_production_update_feed() -> tuple[bool, str]:
)
with urllib.request.urlopen(req, timeout=5) as resp:
if resp.status == 200:
data = json.loads(resp.read().decode("utf-8"))
data = json.loads(resp.read().decode("utf-8-sig"))
if not data.get("version") or not data.get("package_url"):
return False, "Public update manifest is missing version or package_url"
return True, f"Public update manifest live at {DEFAULT_UPDATE_URL} (v{data.get('version')})"

View file

@ -23,14 +23,44 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
}
_SETTINGS_CACHE: Dict[str, Any] | None = None
_SETTINGS_CACHE_MTIME: float = -1.0
_SETTINGS_CACHE_PATH: str = ""
def invalidate_settings_cache() -> None:
"""Clear in-memory settings cache."""
global _SETTINGS_CACHE, _SETTINGS_CACHE_MTIME, _SETTINGS_CACHE_PATH
_SETTINGS_CACHE = None
_SETTINGS_CACHE_MTIME = -1.0
_SETTINGS_CACHE_PATH = ""
def get_settings_file() -> Path:
"""Return the absolute path to hub_settings.json in HERMES_HOME."""
return get_hermes_home() / "hub_settings.json"
def get_hub_settings() -> Dict[str, Any]:
"""Load settings from hub_settings.json merged with standard defaults."""
"""Load settings from hub_settings.json merged with standard defaults, cached by mtime."""
global _SETTINGS_CACHE, _SETTINGS_CACHE_MTIME, _SETTINGS_CACHE_PATH
sfile = get_settings_file()
sfile_str = str(sfile)
current_mtime = -1.0
if sfile.exists():
try:
current_mtime = sfile.stat().st_mtime
except Exception:
current_mtime = -1.0
if (
_SETTINGS_CACHE is not None
and _SETTINGS_CACHE_PATH == sfile_str
and _SETTINGS_CACHE_MTIME == current_mtime
):
return dict(_SETTINGS_CACHE)
merged = dict(DEFAULT_SETTINGS)
if sfile.exists():
try:
@ -56,11 +86,22 @@ def get_hub_settings() -> Dict[str, Any]:
except (ValueError, TypeError):
merged["monitoring_interval_seconds"] = 30
return merged
_SETTINGS_CACHE = dict(merged)
_SETTINGS_CACHE_MTIME = current_mtime
_SETTINGS_CACHE_PATH = sfile_str
return dict(merged)
def save_hub_settings(settings: Dict[str, Any]) -> None:
def save_hub_settings(settings: Dict[str, Any]) -> bool:
"""Persist settings dictionary into hub_settings.json."""
sfile = get_settings_file()
sfile.parent.mkdir(parents=True, exist_ok=True)
sfile.write_text(json.dumps(settings, indent=2, ensure_ascii=False), encoding="utf-8")
try:
sfile = get_settings_file()
sfile.parent.mkdir(parents=True, exist_ok=True)
current = get_hub_settings()
current.update(settings)
sfile.write_text(json.dumps(current, indent=2, ensure_ascii=False), encoding="utf-8")
invalidate_settings_cache()
return True
except Exception:
return False

View file

@ -13,6 +13,7 @@ import hashlib
import json
import logging
import os
import re
import shutil
import subprocess
import sys
@ -85,11 +86,23 @@ ALLOWED_UPDATE_HOSTS = {
def is_allowed_update_host(url: str, allow_dev_local: bool = False) -> bool:
"""Verify that URL points to an authorized release feed host."""
if url.startswith("file://") or Path(url).exists():
return allow_dev_local or os.environ.get("HERMES_HUB_DEV_MODE") == "1"
is_dev = allow_dev_local or os.environ.get("HERMES_HUB_DEV_MODE") == "1"
if url.startswith("file://"):
return is_dev
if re.match(r"^[a-zA-Z]:[/\\]", url) or url.startswith(("\\\\", "./", "../", ".\\", "..\\")):
return is_dev
if not url.startswith("http://") and not url.startswith("https://") and not url.startswith("ftp://"):
if Path(url).is_absolute() or Path(url).exists():
return is_dev
try:
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.scheme.lower() not in ("https", "http"):
return False
if parsed.hostname in ("127.0.0.1", "localhost") and is_dev:
return True
if parsed.scheme.lower() != "https":
return False
hostname = (parsed.hostname or "").lower()
@ -114,7 +127,7 @@ class UpdateManager:
if manifest_dict:
data = manifest_dict
else:
if not is_allowed_update_host(self.manifest_url, allow_dev_local=True):
if not is_allowed_update_host(self.manifest_url, allow_dev_local=False):
raise ValueError(f"Недопустимый хост источника обновлений: {self.manifest_url}")
req = urllib.request.Request(
@ -123,7 +136,7 @@ class UpdateManager:
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8"))
data = json.loads(resp.read().decode("utf-8-sig"))
except urllib.error.HTTPError as http_err:
if http_err.code == 404:
return UpdateCheckResult(
@ -171,7 +184,7 @@ 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=True):
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():

View file

@ -45,7 +45,18 @@ from antigravity_provider.version import __version__
@pytest.mark.unit
def test_p0_1_installer_dependencies():
"""P0-1: Verify that required UI dependencies are importable in runtime."""
"""P0-1: Verify that canonical installer defines dependency installation, smoke testing, and dependencies import in runtime."""
# 1. Verify Canonical Installer Specification in HermesHubSetup.cs
setup_cs = Path(__file__).resolve().parent.parent / "installer" / "HermesHubSetup.cs"
assert setup_cs.exists(), "HermesHubSetup.cs must exist as the canonical installer source"
cs_content = setup_cs.read_text(encoding="utf-8")
assert "EnsurePythonDependencies" in cs_content, "Canonical installer must define dependency checking and installation"
assert "customtkinter" in cs_content and "pillow" in cs_content.lower(), "Canonical installer must install customtkinter and Pillow"
assert "HERMES_HUB_IMPORT_OK" in cs_content, "Canonical installer must execute post-install import smoke test"
assert "assets" in cs_content, "Canonical installer must deploy branding and UI assets"
# 2. Verify Runtime Dependency Availability
pytest.importorskip("customtkinter")
import customtkinter
from PIL import Image

View file

@ -52,17 +52,29 @@ def test_sha256_verification(tmp_path):
@pytest.mark.unit
def test_host_allowlist_validation():
"""Verify that update host allowlist allows GitHub domains and rejects arbitrary/evil URLs."""
# Allowlisted hosts
def test_host_allowlist_validation(monkeypatch):
"""Verify that update host allowlist allows GitHub domains and rejects arbitrary/evil URLs and dev files in prod."""
# Ensure production mode by default
monkeypatch.delenv("HERMES_HUB_DEV_MODE", raising=False)
# 1. Allowlisted production hosts
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
# Untrusted / Malicious hosts
assert is_allowed_update_host("http://evil-server.com/malicious_update.zip") is False
assert is_allowed_update_host("https://evil-server.com/malicious_update.zip") is False
assert is_allowed_update_host("ftp://github.com/pkg.zip") is False
# 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
assert is_allowed_update_host("C:\\local\\update.zip", allow_dev_local=False) is False
assert is_allowed_update_host("http://evil-server.com/malicious_update.zip", allow_dev_local=False) is False
assert is_allowed_update_host("https://evil-server.com/malicious_update.zip", allow_dev_local=False) is False
assert is_allowed_update_host("ftp://github.com/pkg.zip", allow_dev_local=False) is False
# 3. Explicit dev mode ALLOWS local files
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
assert is_allowed_update_host("file:///C:/local/update.zip", allow_dev_local=False) is True
assert is_allowed_update_host("C:\\local\\update.zip", allow_dev_local=False) is True
assert is_allowed_update_host("http://localhost:8000/manifest.json", allow_dev_local=False) is True
assert is_allowed_update_host("https://evil-server.com/malicious_update.zip", allow_dev_local=False) is False
@pytest.mark.unit
@ -91,6 +103,7 @@ def test_manifest_404_friendly_message(tmp_path, monkeypatch):
def test_bad_hash_rejection(tmp_path, monkeypatch):
"""Verify that packages with invalid / tampered hashes are rejected and staging is cleaned."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
# Create dummy zip package
pkg_file = tmp_path / "tampered_pkg.zip"
@ -145,6 +158,7 @@ def test_updater_rollback_on_failure(tmp_path, monkeypatch):
def test_dogfood_update_e2e(tmp_path, monkeypatch):
"""Verify successful end-to-end update from 0.1.1 to 0.1.2."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
# Target app directory
app_dir = tmp_path / "app"