From 083005e44f13d5cd6bda3f1f164026101f744fb8 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 23 Aug 2026 04:42:08 +0000 Subject: [PATCH] fix: verify served Hermes PWA identity --- dockerfiles/Dockerfile.hermes-webui | 26 +++- dockerfiles/hermes-webui-manifest-patch.py | 54 +++++++ dockerfiles/hermes-webui-smoke.py | 127 +++++++++++++++++ docs/hermes_webui_release.md | 10 ++ .../static/manifest.json | 53 +++++++ testing/tests/test_hermes_webui_brand.py | 132 +++++++++++++++++- testing/tests/test_hermes_webui_release.py | 11 ++ 7 files changed, 406 insertions(+), 7 deletions(-) create mode 100644 dockerfiles/hermes-webui-manifest-patch.py create mode 100644 dockerfiles/hermes-webui-smoke.py create mode 100644 testing/fixtures/hermes-webui-0.52.181/static/manifest.json diff --git a/dockerfiles/Dockerfile.hermes-webui b/dockerfiles/Dockerfile.hermes-webui index 2676844c..00ed7b13 100644 --- a/dockerfiles/Dockerfile.hermes-webui +++ b/dockerfiles/Dockerfile.hermes-webui @@ -21,8 +21,10 @@ COPY dockerfiles/hermes-webui-atlas-voice.css /opt/hermes-webui/static/atlas-voi COPY dockerfiles/hermes-webui-router-patch.py /tmp/hermes-webui-router-patch.py COPY dockerfiles/hermes-webui-router.js /opt/hermes-webui/static/atlas-router.js COPY dockerfiles/hermes-webui-brand-patch.py /tmp/hermes-webui-brand-patch.py +COPY dockerfiles/hermes-webui-manifest-patch.py /tmp/hermes-webui-manifest-patch.py +COPY dockerfiles/hermes-webui-smoke.py /tmp/hermes-webui-smoke.py COPY dockerfiles/hermes-webui-brand.css /opt/hermes-webui/static/hermes-brand.css -COPY dockerfiles/hermes-webui-manifest.json /opt/hermes-webui/static/manifest.json +COPY dockerfiles/hermes-webui-manifest.json /tmp/hermes-webui-manifest.json COPY dockerfiles/hermes-webui-assets/hermes-agent.ico /opt/hermes-webui/static/hermes-agent.ico COPY dockerfiles/hermes-webui-assets/hermes-agent-192.png /opt/hermes-webui/static/hermes-agent-192.png COPY dockerfiles/hermes-webui-assets/hermes-agent-512.png /opt/hermes-webui/static/hermes-agent-512.png @@ -32,6 +34,7 @@ RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-stt-patch.py RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-telegram-project-patch.py RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-router-patch.py RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-brand-patch.py +RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-manifest-patch.py RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \ && grep -Fq 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")' \ @@ -81,7 +84,10 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \ /opt/hermes-webui/api/gateway_chat.py \ /opt/hermes/tools/transcription_tools.py -# Exercise the real server process in the target architecture before publish. +# Exercise branded responses from the real upstream server process in the +# target architecture before publish. The manifest checks resolve icon paths +# from the URL the server actually returns; no filesystem-only assertion can +# satisfy this gate. RUN set -eu; \ mkdir -p /tmp/hermes-webui-smoke/home /tmp/hermes-webui-smoke/state /tmp/hermes-webui-smoke/workspace; \ HERMES_HOME=/tmp/hermes-webui-smoke/home \ @@ -93,14 +99,24 @@ RUN set -eu; \ HERMES_WEBUI_SKIP_ONBOARDING=1 \ /opt/hermes/.venv/bin/python /opt/hermes-webui/server.py >/tmp/hermes-webui-smoke.log 2>&1 & \ server_pid=$!; \ + cleanup() { kill "${server_pid}" 2>/dev/null || true; wait "${server_pid}" 2>/dev/null || true; }; \ + trap cleanup EXIT HUP INT TERM; \ ready=0; \ for attempt in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30; do \ if /opt/hermes/.venv/bin/python -c 'from urllib.request import urlopen; urlopen("http://127.0.0.1:18787/health", timeout=2).read()' >/dev/null 2>&1; then ready=1; break; fi; \ sleep 1; \ done; \ - kill "${server_pid}" 2>/dev/null || true; \ - wait "${server_pid}" 2>/dev/null || true; \ - if [ "${ready}" != "1" ]; then cat /tmp/hermes-webui-smoke.log; exit 1; fi; \ + smoke_passed=0; \ + if [ "${ready}" = "1" ] \ + && /opt/hermes/.venv/bin/python /tmp/hermes-webui-smoke.py http://127.0.0.1:18787/; then \ + smoke_passed=1; \ + fi; \ + cleanup; \ + trap - EXIT HUP INT TERM; \ + if [ "${ready}" != "1" ] || [ "${smoke_passed}" != "1" ]; then \ + cat /tmp/hermes-webui-smoke.log; \ + exit 1; \ + fi; \ rm -rf /tmp/hermes-webui-smoke /tmp/hermes-webui-smoke.log ENV HERMES_WEBUI_AGENT_DIR=/opt/hermes \ diff --git a/dockerfiles/hermes-webui-manifest-patch.py b/dockerfiles/hermes-webui-manifest-patch.py new file mode 100644 index 00000000..1966f75a --- /dev/null +++ b/dockerfiles/hermes-webui-manifest-patch.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Install the branded PWA manifest only over the exact pinned upstream file.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path + + +ROOT = Path(os.environ.get("HERMES_WEBUI_PATCH_ROOT", "/opt/hermes-webui")) +SOURCE = Path( + os.environ.get( + "HERMES_WEBUI_MANIFEST_SOURCE", "/tmp/hermes-webui-manifest.json" + ) +) +UPSTREAM_SHA256 = "da3e24d84ae91fba3f8ba51f48d51d277b4f6d443506e888a178ac7d1fed1c6a" + + +manifest_path = ROOT / "static/manifest.json" +upstream = manifest_path.read_bytes() +if hashlib.sha256(upstream).hexdigest() != UPSTREAM_SHA256: + raise SystemExit( + "Hermes manifest patch context changed in " + f"{manifest_path}: pinned upstream SHA-256 mismatch" + ) + +branded = SOURCE.read_bytes() +try: + payload = json.loads(branded) +except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise SystemExit("Tracked Hermes manifest is not valid UTF-8 JSON") from exc + +expected_icons = { + ("static/hermes-agent-192.png", "192x192", "image/png"), + ("static/hermes-agent-512.png", "512x512", "image/png"), +} +actual_icons = { + (icon.get("src"), icon.get("sizes"), icon.get("type")) + for icon in payload.get("icons", []) + if isinstance(icon, dict) +} +if ( + payload.get("name") != "Hermes Chat" + or payload.get("short_name") != "Hermes" + or actual_icons != expected_icons +): + raise SystemExit("Tracked Hermes manifest identity contract changed") + +# Write once, and only after both the installed upstream context and replacement +# contract have passed. This prevents an upstream pin drift from being hidden by +# a wholesale COPY over the served manifest. +manifest_path.write_bytes(branded) diff --git a/dockerfiles/hermes-webui-smoke.py b/dockerfiles/hermes-webui-smoke.py new file mode 100644 index 00000000..95cc1ff3 --- /dev/null +++ b/dockerfiles/hermes-webui-smoke.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Exercise branded PWA responses from a running upstream WebUI server.""" + +from __future__ import annotations + +import json +import sys +from collections.abc import Callable +from typing import Any +from urllib.parse import urljoin +from urllib.request import Request, urlopen + + +PNG_MAGIC = b"\x89PNG\r\n\x1a\n" +PERSONA_LINKS = ( + "static/hermes-agent.ico", + "static/hermes-agent-192.png", + "static/hermes-agent-512.png", +) + + +def _get( + url: str, + opener: Callable[..., Any], +) -> tuple[bytes, str]: + request = Request(url, headers={"Accept-Encoding": "identity"}) + with opener(request, timeout=5) as response: + status = int(getattr(response, "status", 0)) + if status != 200: + raise RuntimeError(f"GET {url} returned HTTP {status}") + body = response.read() + final_url = response.geturl() + return body, final_url + + +def _icon_sources(value: object) -> list[str]: + sources: list[str] = [] + if isinstance(value, dict): + icons = value.get("icons") + if isinstance(icons, list): + for icon in icons: + if not isinstance(icon, dict) or not isinstance(icon.get("src"), str): + raise RuntimeError("manifest contains an invalid icon entry") + if icon.get("type") != "image/png": + raise RuntimeError("manifest contains a non-PNG icon") + sources.append(icon["src"]) + for child in value.values(): + if child is not icons: + sources.extend(_icon_sources(child)) + elif isinstance(value, list): + for child in value: + sources.extend(_icon_sources(child)) + return sources + + +def smoke( + base_url: str, + *, + opener: Callable[..., Any] = urlopen, +) -> dict[str, object]: + base_url = base_url.rstrip("/") + "/" + manifest_body, manifest_url = _get(urljoin(base_url, "manifest.json"), opener) + try: + manifest = json.loads(manifest_body) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RuntimeError("served /manifest.json is not valid branded JSON") from exc + + if manifest.get("name") != "Hermes Chat" or manifest.get("short_name") != "Hermes": + raise RuntimeError("served /manifest.json is not branded for Hermes Chat") + expected = { + "static/hermes-agent-192.png", + "static/hermes-agent-512.png", + } + top_level = manifest.get("icons") + if not isinstance(top_level, list) or { + icon.get("src") for icon in top_level if isinstance(icon, dict) + } != expected: + raise RuntimeError("served /manifest.json has the wrong Hermes icons") + + icon_sources = _icon_sources(manifest) + if not icon_sources: + raise RuntimeError("served /manifest.json has no icon URLs") + for source in icon_sources: + icon_body, _icon_url = _get(urljoin(manifest_url, source), opener) + if not icon_body.startswith(PNG_MAGIC): + raise RuntimeError(f"manifest icon is not PNG: {source}") + + root_body, _root_url = _get(base_url, opener) + try: + root_html = root_body.decode("utf-8") + except UnicodeDecodeError as exc: + raise RuntimeError("served / is not UTF-8 HTML") from exc + for source in PERSONA_LINKS: + if source not in root_html: + raise RuntimeError(f"served / omitted Hermes persona link: {source}") + if "static/hermes-brand.css" not in root_html: + raise RuntimeError("served / omitted static/hermes-brand.css") + + direct_icon, _direct_url = _get( + urljoin(base_url, "static/hermes-agent-192.png"), opener + ) + if not direct_icon.startswith(PNG_MAGIC): + raise RuntimeError("served /static/hermes-agent-192.png is not PNG") + + return { + "manifest": manifest_url, + "icon_requests": len(icon_sources), + "root": base_url, + "direct_icon": "static/hermes-agent-192.png", + } + + +def main() -> int: + if len(sys.argv) != 2: + raise SystemExit("usage: hermes-webui-smoke.py BASE_URL") + result = smoke(sys.argv[1]) + print( + "Hermes WebUI smoke passed: " + f"manifest={result['manifest']} " + f"icon_requests={result['icon_requests']} " + "root=branded direct_icon=png" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/hermes_webui_release.md b/docs/hermes_webui_release.md index 061f8cb6..17fe0ff7 100644 --- a/docs/hermes_webui_release.md +++ b/docs/hermes_webui_release.md @@ -30,6 +30,16 @@ The release fails closed when the requested revision is not the checked-out tag policy is absent, Kaniko and Harbor disagree on the digest, either Flux workload changes identity/image shape, or the evidence archive is incomplete. +Harbor policy bootstrap has an intentional ordering dependency. The existing +`harbor-hermes-agent-immutability-ensure-1` Job grants the shared Jenkins +publisher only the read-only `immutable-tag:list` permission; the WebUI policy +Job creates and verifies the separate `hermes-webui` rule but does not edit the +publisher robot. Flux must therefore complete the existing Hermes agent policy +bootstrap before the WebUI Job and Jenkins release verification. This successor +does not include a WebUI image digest: until its WebUI publisher job and policy +are merged to `main` and bootstrapped, no image can be legitimately published +and independently verified through this lane. + ## PWA identity source The installed application uses the tracked canonical persona at diff --git a/testing/fixtures/hermes-webui-0.52.181/static/manifest.json b/testing/fixtures/hermes-webui-0.52.181/static/manifest.json new file mode 100644 index 00000000..df35bf8d --- /dev/null +++ b/testing/fixtures/hermes-webui-0.52.181/static/manifest.json @@ -0,0 +1,53 @@ +{ + "id": "./", + "name": "Hermes", + "short_name": "Hermes", + "description": "Hermes AI Agent Web UI", + "start_url": "./?source=pwa", + "scope": "./", + "display": "standalone", + "display_override": ["window-controls-overlay", "standalone", "minimal-ui"], + "background_color": "#0D0D1A", + "theme_color": "#0D0D1A", + "orientation": "portrait-primary", + "categories": ["productivity", "utilities"], + "shortcuts": [ + { + "name": "New conversation", + "short_name": "New chat", + "description": "Open Hermes ready for a new chat", + "url": "./?source=pwa&action=new-chat", + "icons": [ + { + "src": "static/favicon-192.png", + "sizes": "192x192", + "type": "image/png" + } + ] + } + ], + "icons": [ + { + "src": "static/favicon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + }, + { + "src": "static/favicon-32.png", + "sizes": "32x32", + "type": "image/png" + }, + { + "src": "static/favicon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "static/favicon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/testing/tests/test_hermes_webui_brand.py b/testing/tests/test_hermes_webui_brand.py index cb5c8f05..48ae1b58 100644 --- a/testing/tests/test_hermes_webui_brand.py +++ b/testing/tests/test_hermes_webui_brand.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import importlib.util import json import os from pathlib import Path @@ -10,6 +11,9 @@ import shutil import struct import subprocess import sys +from urllib.parse import urlsplit + +import pytest ROOT = Path(__file__).resolve().parents[2] DOCKERFILES = ROOT / "dockerfiles" @@ -17,6 +21,8 @@ FIXTURE = ROOT / "testing/fixtures/hermes-webui-0.52.181" AGENT_FIXTURE = ROOT / "testing/fixtures/hermes-agent" ATLAS_PATCHER = DOCKERFILES / "hermes-webui-atlas-patch.py" BRAND_PATCHER = DOCKERFILES / "hermes-webui-brand-patch.py" +MANIFEST_PATCHER = DOCKERFILES / "hermes-webui-manifest-patch.py" +SMOKE = DOCKERFILES / "hermes-webui-smoke.py" ASSETS = DOCKERFILES / "hermes-webui-assets" MANIFEST = DOCKERFILES / "hermes-webui-manifest.json" BRAND_CSS = DOCKERFILES / "hermes-webui-brand.css" @@ -28,6 +34,14 @@ EXPECTED_HASHES = { } +def _load(path: Path, name: str): + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + def _patched_fixture(tmp_path: Path) -> Path: target = tmp_path / "hermes-webui" agent_target = tmp_path / "hermes-agent" @@ -36,7 +50,8 @@ def _patched_fixture(tmp_path: Path) -> Path: env = os.environ.copy() env["HERMES_WEBUI_PATCH_ROOT"] = str(target) env["HERMES_AGENT_PATCH_ROOT"] = str(agent_target) - for patcher in (ATLAS_PATCHER, BRAND_PATCHER): + env["HERMES_WEBUI_MANIFEST_SOURCE"] = str(MANIFEST) + for patcher in (ATLAS_PATCHER, BRAND_PATCHER, MANIFEST_PATCHER): subprocess.run( [sys.executable, str(patcher)], cwd=ROOT, @@ -163,6 +178,109 @@ def test_brand_patch_rejects_upstream_drift_before_partial_success( assert "brand patch context changed" in result.stderr +def test_manifest_patch_rejects_drift_before_wholesale_replacement( + tmp_path: Path, +) -> None: + """The branded file cannot hide a changed manifest in the pinned image.""" + target = tmp_path / "hermes-webui" + shutil.copytree(FIXTURE, target) + installed = target / "static/manifest.json" + installed.write_text( + installed.read_text(encoding="utf-8").replace( + '"name": "Hermes"', '"name": "Upstream drift"', 1 + ), + encoding="utf-8", + ) + before = installed.read_bytes() + env = os.environ.copy() + env["HERMES_WEBUI_PATCH_ROOT"] = str(target) + env["HERMES_WEBUI_MANIFEST_SOURCE"] = str(MANIFEST) + result = subprocess.run( + [sys.executable, str(MANIFEST_PATCHER)], + cwd=ROOT, + env=env, + check=False, + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "pinned upstream SHA-256 mismatch" in result.stderr + assert installed.read_bytes() == before + + +class _SmokeResponse: + status = 200 + + def __init__(self, body: bytes, url: str) -> None: + self.body = body + self.url = url + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def read(self) -> bytes: + return self.body + + def geturl(self) -> str: + return self.url + + +def test_server_smoke_resolves_served_manifest_icons_and_checks_root() -> None: + """The image gate validates HTTP responses, not its own source files.""" + module = _load(SMOKE, "hermes_webui_smoke_contract") + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + root = ( + b'' + b'' + b'' + b'' + ) + calls = [] + + def open_fixture(request, *, timeout): + assert timeout == 5 + url = request.full_url + calls.append(url) + path = urlsplit(url).path + if path == "/manifest.json": + return _SmokeResponse( + json.dumps(manifest).encode(), + "http://assets.hermes.test/pwa/manifest.json", + ) + if path == "/": + return _SmokeResponse(root, url) + if path in ( + "/pwa/static/hermes-agent-192.png", + "/pwa/static/hermes-agent-512.png", + "/static/hermes-agent-192.png", + ): + return _SmokeResponse(module.PNG_MAGIC + b"fixture", url) + raise AssertionError(f"unexpected smoke URL: {url}") + + result = module.smoke("http://hermes.test/", opener=open_fixture) + assert result["manifest"] == "http://assets.hermes.test/pwa/manifest.json" + assert result["icon_requests"] == 3 + assert calls.count("http://assets.hermes.test/pwa/static/hermes-agent-192.png") == 2 + assert "http://assets.hermes.test/pwa/static/hermes-agent-512.png" in calls + assert calls.count("http://hermes.test/static/hermes-agent-192.png") == 1 + + +def test_server_smoke_rejects_unbranded_served_manifest() -> None: + """A successful health check cannot mask the upstream PWA identity.""" + module = _load(SMOKE, "hermes_webui_smoke_unbranded") + + def open_upstream(request, *, timeout): + assert timeout == 5 + payload = (FIXTURE / "static/manifest.json").read_bytes() + return _SmokeResponse(payload, request.full_url) + + with pytest.raises(RuntimeError, match="not branded"): + module.smoke("http://hermes.test/", opener=open_upstream) + + def test_brand_css_is_accessible_dark_and_reduced_motion_aware() -> None: """Identity colors retain system controls and disable cosmetic motion.""" css = BRAND_CSS.read_text(encoding="utf-8") @@ -185,7 +303,17 @@ def test_dockerfile_copies_and_verifies_every_tracked_brand_asset() -> None: assert "/opt/hermes/web/public/favicon.ico" not in dockerfile assert "COPY dockerfiles/hermes-webui-brand-patch.py" in dockerfile assert "python /tmp/hermes-webui-brand-patch.py" in dockerfile - assert "COPY dockerfiles/hermes-webui-manifest.json" in dockerfile + assert "COPY dockerfiles/hermes-webui-manifest-patch.py" in dockerfile + assert "python /tmp/hermes-webui-manifest-patch.py" in dockerfile + assert ( + "COPY dockerfiles/hermes-webui-manifest.json /tmp/hermes-webui-manifest.json" + in dockerfile + ) + assert ( + "COPY dockerfiles/hermes-webui-manifest.json /opt/hermes-webui/static/manifest.json" + not in dockerfile + ) + assert "python /tmp/hermes-webui-smoke.py http://127.0.0.1:18787/" in dockerfile assert "COPY dockerfiles/hermes-webui-brand.css" in dockerfile for name, digest in EXPECTED_HASHES.items(): assert f"COPY dockerfiles/hermes-webui-assets/{name}" in dockerfile diff --git a/testing/tests/test_hermes_webui_release.py b/testing/tests/test_hermes_webui_release.py index 3e77d118..0ab35dfa 100644 --- a/testing/tests/test_hermes_webui_release.py +++ b/testing/tests/test_hermes_webui_release.py @@ -124,9 +124,14 @@ def test_webui_dockerfile_is_kaniko_safe_and_uses_reviewed_repo_source() -> None assert "<<" not in source assert "hermes-webui-base-patch.py" in source assert "hermes-webui-brand-patch.py" in source + assert "hermes-webui-manifest-patch.py" in source + assert "hermes-webui-smoke.py" in source assert "hermes-webui-stt-patch.py" in source assert "hermes-webui-atlas-voice.js" in source assert "hermes-webui-manifest.json" in source + assert "urljoin(manifest_url, source)" in ( + ROOT / "dockerfiles/hermes-webui-smoke.py" + ).read_text(encoding="utf-8") def test_renderer_updates_exact_chat_and_dashboard_webui_only(tmp_path: Path) -> None: @@ -374,6 +379,12 @@ def test_flux_tracks_webui_policy_before_jenkins() -> None: assert container["securityContext"]["runAsNonRoot"] is True assert container["securityContext"]["capabilities"]["drop"] == ["ALL"] + release_docs = (ROOT / "docs/hermes_webui_release.md").read_text( + encoding="utf-8" + ) + assert "immutable-tag:list" in release_docs + assert "harbor-hermes-agent-immutability-ensure-1" in release_docs + class _FakePolicyClient: origin = "https://registry.bstein.dev/api/v2.0"