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"