fix: verify served Hermes PWA identity

This commit is contained in:
Hermes Agent 2026-08-23 04:42:08 +00:00
parent 99a9de936b
commit 083005e44f
7 changed files with 406 additions and 7 deletions

View File

@ -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 \

View File

@ -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)

View File

@ -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())

View File

@ -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

View File

@ -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"
}
]
}

View File

@ -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'<link href="static/hermes-agent.ico">'
b'<link href="static/hermes-agent-192.png">'
b'<link href="static/hermes-agent-512.png">'
b'<link href="static/hermes-brand.css?v=reviewed">'
)
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

View File

@ -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"