diff --git a/docs/audits/2026-08-20-v0.1.1-final-review-request.md b/docs/audits/2026-08-20-v0.1.1-final-review-request.md new file mode 100644 index 0000000..6bc2258 --- /dev/null +++ b/docs/audits/2026-08-20-v0.1.1-final-review-request.md @@ -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 (A–J) + +### 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 +====================================================================== +``` diff --git a/installer/HermesHubSetup.cs b/installer/HermesHubSetup.cs index 329bb69..fbae1ed 100644 --- a/installer/HermesHubSetup.cs +++ b/installer/HermesHubSetup.cs @@ -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 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 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 } } } diff --git a/scripts/release_gate.py b/scripts/release_gate.py index 87538fd..62bf829 100644 --- a/scripts/release_gate.py +++ b/scripts/release_gate.py @@ -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')})" diff --git a/src/antigravity_provider/router/settings_service.py b/src/antigravity_provider/router/settings_service.py index 71ebb13..6fcd7eb 100644 --- a/src/antigravity_provider/router/settings_service.py +++ b/src/antigravity_provider/router/settings_service.py @@ -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 diff --git a/src/antigravity_provider/updater/update_manager.py b/src/antigravity_provider/updater/update_manager.py index c64dc10..78ee050 100644 --- a/src/antigravity_provider/updater/update_manager.py +++ b/src/antigravity_provider/updater/update_manager.py @@ -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(): diff --git a/tests/test_p0_release_gate.py b/tests/test_p0_release_gate.py index 8b75c0d..88a9e68 100644 --- a/tests/test_p0_release_gate.py +++ b/tests/test_p0_release_gate.py @@ -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 diff --git a/tests/test_updater.py b/tests/test_updater.py index 30644f1..d9cf3e2 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -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"