diff --git a/ci/Jenkinsfile.hermes-webui-image b/ci/Jenkinsfile.hermes-webui-image
new file mode 100644
index 00000000..859f9809
--- /dev/null
+++ b/ci/Jenkinsfile.hermes-webui-image
@@ -0,0 +1,314 @@
+pipeline {
+ agent {
+ kubernetes {
+ defaultContainer 'python'
+ yaml """
+apiVersion: v1
+kind: Pod
+metadata:
+ labels:
+ atlas.bstein.dev/workload: hermes-webui-image-builder
+spec:
+ serviceAccountName: hermes-image-builder
+ automountServiceAccountToken: false
+ enableServiceLinks: false
+ restartPolicy: Never
+ securityContext:
+ fsGroup: 1000
+ fsGroupChangePolicy: OnRootMismatch
+ nodeSelector:
+ kubernetes.io/arch: arm64
+ node-role.kubernetes.io/worker: "true"
+ affinity:
+ nodeAffinity:
+ requiredDuringSchedulingIgnoredDuringExecution:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: NotIn
+ values:
+ - titan-04
+ - titan-14
+ - titan-18
+ - titan-19
+ - titan-22
+ - titan-24
+ preferredDuringSchedulingIgnoredDuringExecution:
+ - weight: 100
+ preference:
+ matchExpressions:
+ - key: hardware
+ operator: In
+ values:
+ - rpi5
+ imagePullSecrets:
+ - name: harbor-bstein-robot
+ containers:
+ - name: jnlp
+ image: jenkins/inbound-agent@sha256:8eda4fe2a66bcf6a5e43436d9918fc14c306204dc8fcd75f4e15e0e6e5dc759a
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop: ["ALL"]
+ runAsNonRoot: true
+ runAsUser: 1000
+ seccompProfile:
+ type: RuntimeDefault
+ resources:
+ requests:
+ cpu: 25m
+ memory: 256Mi
+ limits:
+ cpu: 500m
+ memory: 512Mi
+ - name: python
+ image: registry.bstein.dev/bstein/python@sha256:269541d3387baae008df4608ead893dba2b5cdaad1a5a380731a88992d34b808
+ command: ["sleep"]
+ args: ["99d"]
+ tty: true
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop: ["ALL"]
+ runAsNonRoot: true
+ runAsUser: 1000
+ seccompProfile:
+ type: RuntimeDefault
+ resources:
+ requests:
+ cpu: 25m
+ memory: 64Mi
+ limits:
+ cpu: 250m
+ memory: 256Mi
+ - name: kaniko
+ image: gcr.io/kaniko-project/executor@sha256:c3109d5926a997b100c4343944e06c6b30a6804b2f9abe0994d3de6ef92b028e
+ command: ["/busybox/sh", "-c"]
+ args: ["/busybox/sleep 99d"]
+ tty: true
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop: ["ALL"]
+ add: ["CHOWN", "FOWNER", "DAC_OVERRIDE", "SETGID", "SETUID"]
+ privileged: false
+ runAsUser: 0
+ seccompProfile:
+ type: RuntimeDefault
+ resources:
+ requests:
+ cpu: 250m
+ memory: 1Gi
+ limits:
+ cpu: "2"
+ memory: 4Gi
+"""
+ }
+ }
+ parameters {
+ booleanParam(
+ name: 'PUBLISH_IMAGE',
+ defaultValue: false,
+ description: 'Publish the reviewed main revision to Harbor.'
+ )
+ string(
+ name: 'EXPECTED_SOURCE_REVISION',
+ defaultValue: '',
+ description: 'Exact 40-character commit on atlas/titan-iac main.'
+ )
+ string(
+ name: 'CONFIRM_PUBLISH',
+ defaultValue: '',
+ description: 'Enter PUBLISH HERMES WEBUI to confirm the release.'
+ )
+ }
+ environment {
+ HERMES_IMAGE = 'registry.bstein.dev/bstein/hermes-webui'
+ }
+ options {
+ disableConcurrentBuilds()
+ buildDiscarder(logRotator(daysToKeepStr: '30', numToKeepStr: '100', artifactDaysToKeepStr: '30', artifactNumToKeepStr: '100'))
+ skipDefaultCheckout(true)
+ timeout(time: 150, unit: 'MINUTES')
+ }
+ stages {
+ stage('Checkout reviewed source') {
+ steps {
+ checkout scm
+ }
+ }
+ stage('Enforce release boundary') {
+ steps {
+ container('jnlp') {
+ sh '''
+ set -eu
+ mkdir -p build
+ test "${PUBLISH_IMAGE}" = "true"
+ test "${CONFIRM_PUBLISH}" = "PUBLISH HERMES WEBUI"
+ case "${EXPECTED_SOURCE_REVISION}" in
+ *[!0-9a-f]*|'')
+ echo "EXPECTED_SOURCE_REVISION must be a lowercase full commit" >&2
+ exit 2
+ ;;
+ esac
+ test "${#EXPECTED_SOURCE_REVISION}" -eq 40
+ actual_revision="$(git rev-parse HEAD)"
+ test "${actual_revision}" = "${EXPECTED_SOURCE_REVISION}"
+ test "${actual_revision}" = "$(git rev-parse origin/main)"
+ test -z "$(git status --porcelain)"
+ test -f dockerfiles/Dockerfile.hermes-webui
+ case "${BUILD_NUMBER}" in
+ ''|0*|*[!0-9]*)
+ echo "BUILD_NUMBER must be a positive decimal integer" >&2
+ exit 2
+ ;;
+ esac
+ printf '%s\n' \
+ "${HERMES_IMAGE}:git-${actual_revision}-build-${BUILD_NUMBER}" \
+ > build/hermes-webui.destination
+ '''
+ }
+ }
+ }
+ stage('Validate reviewed WebUI source') {
+ steps {
+ container('python') {
+ sh '''
+ set -eu
+ python3 -m pip install --disable-pip-version-check --no-cache-dir \
+ --target=/tmp/hermes-webui-release-test-deps \
+ pytest==8.3.4 PyYAML==6.0.2
+ PYTHONPATH=/tmp/hermes-webui-release-test-deps \
+ python3 -m pytest -q \
+ testing/tests/test_hermes_webui_brand.py \
+ testing/tests/test_hermes_webui_release.py
+ '''
+ }
+ }
+ }
+ stage('Reject replay before publish') {
+ steps {
+ withCredentials([usernamePassword(
+ credentialsId: 'harbor-robot',
+ usernameVariable: 'HARBOR_USER',
+ passwordVariable: 'HARBOR_PASSWORD'
+ )]) {
+ sh '''
+ set -eu
+ set +x
+ destination="$(cat build/hermes-webui.destination)"
+ python3 ci/scripts/hermes_webui_release.py assert-absent \
+ --source-revision "${EXPECTED_SOURCE_REVISION}" \
+ --build-number "${BUILD_NUMBER}" \
+ --destination "${destination}"
+ '''
+ }
+ }
+ }
+ stage('Build and publish without a daemon') {
+ steps {
+ container('kaniko') {
+ withCredentials([usernamePassword(
+ credentialsId: 'harbor-robot',
+ usernameVariable: 'HARBOR_USER',
+ passwordVariable: 'HARBOR_PASSWORD'
+ )]) {
+ sh '''#!/busybox/sh
+ set -eu
+ set +x
+ config_path=/kaniko/.docker/config.json
+ destination="$(cat build/hermes-webui.destination)"
+ umask 077
+ auth="$(printf '%s:%s' "${HARBOR_USER}" "${HARBOR_PASSWORD}" | /busybox/base64 | /busybox/tr -d '\n')"
+ /busybox/mkdir -p /kaniko/.docker
+ /busybox/printf '{"auths":{"registry.bstein.dev":{"auth":"%s"}}}\n' "${auth}" > "${config_path}"
+ unset HARBOR_USER HARBOR_PASSWORD auth
+ trap '/busybox/rm -f "${config_path}"' EXIT HUP INT TERM
+ umask 022
+ /kaniko/executor \
+ --registry-mirror=harbor-core.harbor.svc.cluster.local \
+ --insecure-registry=harbor-core.harbor.svc.cluster.local \
+ --context="dir://${WORKSPACE}" \
+ --dockerfile="${WORKSPACE}/dockerfiles/Dockerfile.hermes-webui" \
+ --destination="${destination}" \
+ --digest-file="${WORKSPACE}/build/hermes-webui.digest" \
+ --image-name-tag-with-digest-file="${WORKSPACE}/build/hermes-webui.image" \
+ --label="org.opencontainers.image.revision=${EXPECTED_SOURCE_REVISION}" \
+ --label="org.opencontainers.image.source=https://scm.bstein.dev/atlas/titan-iac" \
+ --label="org.opencontainers.image.title=hermes-webui" \
+ --cleanup \
+ --push-retry=3
+ /busybox/chmod 644 build/hermes-webui.digest build/hermes-webui.image
+ '''
+ }
+ }
+ }
+ }
+ stage('Render reviewed Flux handoff') {
+ steps {
+ withCredentials([usernamePassword(
+ credentialsId: 'harbor-robot',
+ usernameVariable: 'HARBOR_USER',
+ passwordVariable: 'HARBOR_PASSWORD'
+ )]) {
+ sh '''
+ set -eu
+ set +x
+ destination="$(cat build/hermes-webui.destination)"
+ python3 ci/scripts/hermes_webui_release.py render \
+ --digest-file build/hermes-webui.digest \
+ --image-file build/hermes-webui.image \
+ --source-revision "${EXPECTED_SOURCE_REVISION}" \
+ --build-number "${BUILD_NUMBER}" \
+ --destination "${destination}" \
+ --chat-manifest services/hermes/chat-statefulset.yaml \
+ --dashboard-manifest services/hermes/deployment.yaml \
+ --output-dir build/hermes-webui-release
+ test -s build/hermes-webui-release/hermes-webui-image-update.patch
+ test -s build/hermes-webui-release/hermes-webui-image.json
+ '''
+ }
+ }
+ }
+ }
+ post {
+ success {
+ sh '''
+ set -eu
+ expected_files="$(printf '%s\n' \
+ build/hermes-webui.destination \
+ build/hermes-webui.digest \
+ build/hermes-webui.image \
+ build/hermes-webui-release/hermes-chat-statefulset.yaml \
+ build/hermes-webui-release/hermes-dashboard-deployment.yaml \
+ build/hermes-webui-release/hermes-webui-image.json \
+ build/hermes-webui-release/hermes-webui-image-update.patch \
+ | LC_ALL=C sort)"
+ actual_files="$(find build -type f -print | LC_ALL=C sort)"
+ test "${actual_files}" = "${expected_files}"
+ destination="$(cat build/hermes-webui.destination)"
+ python3 ci/scripts/hermes_webui_release.py verify-evidence \
+ --digest-file build/hermes-webui.digest \
+ --image-file build/hermes-webui.image \
+ --source-revision "${EXPECTED_SOURCE_REVISION}" \
+ --build-number "${BUILD_NUMBER}" \
+ --destination "${destination}" \
+ --chat-manifest services/hermes/chat-statefulset.yaml \
+ --dashboard-manifest services/hermes/deployment.yaml \
+ --output-dir build/hermes-webui-release
+ '''
+ archiveArtifacts(
+ artifacts: 'build/hermes-webui.destination,build/hermes-webui.digest,build/hermes-webui.image,build/hermes-webui-release/hermes-chat-statefulset.yaml,build/hermes-webui-release/hermes-dashboard-deployment.yaml,build/hermes-webui-release/hermes-webui-image.json,build/hermes-webui-release/hermes-webui-image-update.patch',
+ allowEmptyArchive: false,
+ fingerprint: true
+ )
+ }
+ cleanup {
+ container('kaniko') {
+ sh '''#!/busybox/sh
+ /busybox/rm -f /kaniko/.docker/config.json
+ '''
+ }
+ }
+ }
+}
diff --git a/ci/scripts/hermes_webui_flux_release.py b/ci/scripts/hermes_webui_flux_release.py
new file mode 100644
index 00000000..5483b333
--- /dev/null
+++ b/ci/scripts/hermes_webui_flux_release.py
@@ -0,0 +1,207 @@
+#!/usr/bin/env python3
+"""Render and revalidate the two-workload Hermes WebUI Flux handoff."""
+
+from __future__ import annotations
+
+import difflib
+import json
+import re
+from pathlib import Path
+from typing import Any
+
+
+DEFAULT_IMAGE = "registry.bstein.dev/bstein/hermes-webui"
+DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
+REVISION_PATTERN = re.compile(r"^[0-9a-f]{40}$")
+BUILD_PATTERN = re.compile(r"^[1-9][0-9]*$")
+DESTINATION_PATTERN = re.compile(
+ r"^registry\.bstein\.dev/bstein/hermes-webui:"
+ r"git-([0-9a-f]{40})-build-([1-9][0-9]*)$"
+)
+
+
+def validated(value: str, pattern: re.Pattern[str], label: str) -> str:
+ """Return a normalized value when it matches the release contract."""
+ normalized = value.strip()
+ if not pattern.fullmatch(normalized):
+ raise ValueError(f"invalid {label}: expected {pattern.pattern}")
+ return normalized
+
+
+def validate_destination(
+ destination: str, source_revision: str, build_number: str
+) -> tuple[str, str]:
+ """Bind one unique build tag to the reviewed revision and Jenkins build."""
+ revision = validated(source_revision, REVISION_PATTERN, "source revision")
+ build = validated(build_number, BUILD_PATTERN, "build number")
+ match = DESTINATION_PATTERN.fullmatch(destination.strip())
+ if not match or match.groups() != (revision, build):
+ raise ValueError(
+ "destination must bind the reviewed revision and unique Jenkins build"
+ )
+ return revision, build
+
+
+def render_workload(
+ source: str,
+ digest: str,
+ *,
+ kind: str,
+ name: str,
+ image: str = DEFAULT_IMAGE,
+) -> str:
+ """Replace one WebUI image in one exact Flux workload without reformatting."""
+ digest = validated(digest, DIGEST_PATTERN, "image digest")
+ identity = re.compile(
+ rf"\A(?:#[^\n]*\n)*apiVersion: apps/v1\nkind: {re.escape(kind)}\n"
+ rf"metadata:\n name: {re.escape(name)}\n"
+ )
+ if not identity.search(source):
+ raise ValueError(f"Flux target identity changed: expected {kind}/{name}")
+ lines = source.splitlines(keepends=True)
+ matches: list[int] = []
+ for index, line in enumerate(lines):
+ stripped = line.strip()
+ if not stripped.startswith(f"image: {image}@"):
+ continue
+ current_digest = stripped.removeprefix(f"image: {image}@")
+ validated(current_digest, DIGEST_PATTERN, "current Flux image digest")
+ matches.append(index)
+ if len(matches) != 1:
+ raise ValueError(
+ f"expected exactly one {image!r} image in {kind}/{name}; "
+ f"found {len(matches)}"
+ )
+ index = matches[0]
+ newline = "\n" if lines[index].endswith("\n") else ""
+ prefix = lines[index][: len(lines[index]) - len(lines[index].lstrip())]
+ lines[index] = f"{prefix}image: {image}@{digest}{newline}"
+ return "".join(lines)
+
+
+def _targets(chat_manifest: Path, dashboard_manifest: Path):
+ return (
+ (
+ chat_manifest,
+ "StatefulSet",
+ "hermes-chat-tenant",
+ "hermes-chat-statefulset.yaml",
+ ),
+ (
+ dashboard_manifest,
+ "Deployment",
+ "hermes",
+ "hermes-dashboard-deployment.yaml",
+ ),
+ )
+
+
+def _metadata(
+ digest: str, source_revision: str, build_number: str, destination: str
+) -> dict[str, Any]:
+ return {
+ "build_number": build_number,
+ "digest": digest,
+ "flux_image": f"{DEFAULT_IMAGE}@{digest}",
+ "flux_targets": [
+ "apps/StatefulSet/hermes/hermes-chat-tenant",
+ "apps/Deployment/hermes/hermes",
+ ],
+ "image": DEFAULT_IMAGE,
+ "published_tag": destination,
+ "source_revision": source_revision,
+ }
+
+
+def _rendered_and_patch(
+ digest: str, chat_manifest: Path, dashboard_manifest: Path
+) -> tuple[dict[str, str], str]:
+ rendered_targets: dict[str, str] = {}
+ patch_parts: list[str] = []
+ for path, kind, name, artifact_name in _targets(chat_manifest, dashboard_manifest):
+ source = path.read_text(encoding="utf-8")
+ rendered = render_workload(source, digest, kind=kind, name=name)
+ rendered_targets[artifact_name] = rendered
+ patch_parts.append(
+ "".join(
+ difflib.unified_diff(
+ source.splitlines(keepends=True),
+ rendered.splitlines(keepends=True),
+ fromfile=f"a/services/hermes/{path.name}",
+ tofile=f"b/services/hermes/{path.name}",
+ )
+ )
+ )
+ return rendered_targets, "".join(patch_parts)
+
+
+def write_release_artifacts(
+ *,
+ digest: str,
+ source_revision: str,
+ build_number: str,
+ destination: str,
+ chat_manifest: Path,
+ dashboard_manifest: Path,
+ output_dir: Path,
+) -> dict[str, Any]:
+ """Write two rendered Flux targets, one patch, and credential-free evidence."""
+ digest = validated(digest, DIGEST_PATTERN, "image digest")
+ source_revision, build_number = validate_destination(
+ destination, source_revision, build_number
+ )
+ rendered_targets, patch = _rendered_and_patch(
+ digest, chat_manifest, dashboard_manifest
+ )
+ if not patch:
+ raise ValueError("published digest already matches every Flux target")
+ output_dir.mkdir(parents=True, exist_ok=True)
+ for artifact_name, rendered in rendered_targets.items():
+ (output_dir / artifact_name).write_text(rendered, encoding="utf-8")
+ (output_dir / "hermes-webui-image-update.patch").write_text(patch, encoding="utf-8")
+ metadata = _metadata(digest, source_revision, build_number, destination)
+ (output_dir / "hermes-webui-image.json").write_text(
+ json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8"
+ )
+ return metadata
+
+
+def validate_release_artifacts(
+ *,
+ digest: str,
+ source_revision: str,
+ build_number: str,
+ destination: str,
+ chat_manifest: Path,
+ dashboard_manifest: Path,
+ output_dir: Path,
+) -> None:
+ """Revalidate the exact successful-build evidence without rewriting it."""
+ digest = validated(digest, DIGEST_PATTERN, "image digest")
+ source_revision, build_number = validate_destination(
+ destination, source_revision, build_number
+ )
+ expected_names = {
+ "hermes-webui-image.json",
+ "hermes-webui-image-update.patch",
+ "hermes-chat-statefulset.yaml",
+ "hermes-dashboard-deployment.yaml",
+ }
+ entries = list(output_dir.iterdir())
+ if {entry.name for entry in entries} != expected_names or not all(
+ entry.is_file() and not entry.is_symlink() for entry in entries
+ ):
+ raise ValueError("release output must contain exactly four evidence files")
+ rendered_targets, patch = _rendered_and_patch(
+ digest, chat_manifest, dashboard_manifest
+ )
+ metadata = _metadata(digest, source_revision, build_number, destination)
+ expected = {
+ "hermes-webui-image.json": json.dumps(metadata, indent=2, sort_keys=True)
+ + "\n",
+ "hermes-webui-image-update.patch": patch,
+ **rendered_targets,
+ }
+ for name, expected_text in expected.items():
+ if (output_dir / name).read_text(encoding="utf-8") != expected_text:
+ raise ValueError(f"release evidence is incomplete or mismatched: {name}")
diff --git a/ci/scripts/hermes_webui_release.py b/ci/scripts/hermes_webui_release.py
new file mode 100755
index 00000000..1a744508
--- /dev/null
+++ b/ci/scripts/hermes_webui_release.py
@@ -0,0 +1,374 @@
+#!/usr/bin/env python3
+"""Verify and render a reviewable Hermes WebUI image release."""
+
+from __future__ import annotations
+
+import argparse
+import base64
+import json
+import os
+import urllib.error
+import urllib.parse
+import urllib.request
+from pathlib import Path
+from typing import Any, Callable
+
+from hermes_webui_flux_release import (
+ DEFAULT_IMAGE as DEFAULT_IMAGE,
+ DESTINATION_PATTERN,
+ DIGEST_PATTERN,
+ render_workload as render_workload,
+ validate_destination,
+ validate_release_artifacts as validate_flux_release_artifacts,
+ validated as _validated,
+ write_release_artifacts,
+)
+
+HARBOR_API_ORIGIN = "https://registry.bstein.dev/api/v2.0"
+HARBOR_PROJECT = "bstein"
+HARBOR_REPOSITORY = "hermes-webui"
+IMMUTABLE_REPOSITORY_PATTERN = "hermes-webui"
+IMMUTABLE_TAG_PATTERN = "git-*-build-*"
+
+
+class _NoRedirect(urllib.request.HTTPRedirectHandler):
+ """Never send registry credentials to a redirect target."""
+
+ def redirect_request(self, _request, _file, _code, _message, _headers, _url):
+ return None
+
+
+def validate_kaniko_evidence(
+ *, digest_text: str, image_text: str, destination: str
+) -> str:
+ """Cross-check both independent Kaniko output files against the destination."""
+ digest_lines = digest_text.splitlines()
+ image_lines = image_text.splitlines()
+ if len(digest_lines) != 1:
+ raise ValueError("Kaniko digest evidence must contain exactly one line")
+ if len(image_lines) != 1:
+ raise ValueError("Kaniko image evidence must contain exactly one line")
+ digest = _validated(digest_lines[0], DIGEST_PATTERN, "image digest")
+ if image_lines[0].strip() != f"{destination}@{digest}":
+ raise ValueError("Kaniko image evidence does not match destination and digest")
+ return digest
+
+
+def _registry_request(request: urllib.request.Request, timeout: int) -> Any:
+ """Make a registry request without following redirects."""
+ opener = urllib.request.build_opener(_NoRedirect())
+ try:
+ return opener.open(request, timeout=timeout)
+ except urllib.error.HTTPError as exc:
+ return exc
+
+
+def _artifact_response(
+ destination: str,
+ *,
+ username: str,
+ password: str,
+ opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
+) -> tuple[int, bytes]:
+ """Read one exact Harbor artifact by tag with bounded response size."""
+ match = DESTINATION_PATTERN.fullmatch(destination)
+ if not match:
+ raise ValueError("invalid destination")
+ if not username or not password:
+ raise RuntimeError("Harbor credentials are empty")
+ tag = destination.rsplit(":", 1)[1]
+ encoded_tag = urllib.parse.quote(tag, safe="")
+ auth = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
+ request = urllib.request.Request(
+ f"{HARBOR_API_ORIGIN}/projects/{HARBOR_PROJECT}/repositories/"
+ f"{HARBOR_REPOSITORY}/artifacts/{encoded_tag}"
+ "?with_immutable_status=true",
+ headers={"Accept": "application/json", "Authorization": f"Basic {auth}"},
+ method="GET",
+ )
+ with opener(request, 20) as response:
+ body = response.read(1_048_577)
+ if len(body) > 1_048_576:
+ raise RuntimeError("Harbor artifact response exceeded the size limit")
+ return int(response.status), body
+
+
+def _immutable_rules_response(
+ *,
+ username: str,
+ password: str,
+ opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
+) -> tuple[int, bytes, dict[str, str]]:
+ """Read the project policy with the same least-privilege publish identity."""
+ if not username or not password:
+ raise RuntimeError("Harbor credentials are empty")
+ auth = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
+ request = urllib.request.Request(
+ f"{HARBOR_API_ORIGIN}/projects/{HARBOR_PROJECT}/immutabletagrules"
+ "?page=1&page_size=100",
+ headers={"Accept": "application/json", "Authorization": f"Basic {auth}"},
+ method="GET",
+ )
+ with opener(request, 20) as response:
+ body = response.read(1_048_577)
+ if len(body) > 1_048_576:
+ raise RuntimeError("Harbor immutable rule response exceeded the size limit")
+ return int(response.status), body, dict(response.headers)
+
+
+def _require_complete_rule_page(
+ rules: list[dict[str, Any]], headers: dict[str, str]
+) -> None:
+ """Require proof that the bounded first page contains every rule."""
+ raw_total = next(
+ (value for key, value in headers.items() if key.lower() == "x-total-count"),
+ None,
+ )
+ if raw_total is None or not str(raw_total).isdecimal():
+ raise RuntimeError("Harbor immutable rule list omitted a valid total count")
+ if int(raw_total) != len(rules):
+ raise RuntimeError("Harbor immutable rule list was truncated")
+
+
+def _normalized_immutable_rule(rule: dict[str, Any]) -> dict[str, Any]:
+ """Select only fields that bind the server-side build-tag policy."""
+ return {
+ "disabled": bool(rule.get("disabled", False)),
+ "action": rule.get("action"),
+ "template": rule.get("template"),
+ "tag_selectors": [
+ {
+ "kind": item.get("kind"),
+ "decoration": item.get("decoration"),
+ "pattern": item.get("pattern"),
+ }
+ for item in rule.get("tag_selectors") or []
+ if isinstance(item, dict)
+ ],
+ "scope_selectors": {
+ "repository": [
+ {
+ "kind": item.get("kind"),
+ "decoration": item.get("decoration"),
+ "pattern": item.get("pattern"),
+ }
+ for item in (rule.get("scope_selectors") or {}).get("repository", [])
+ if isinstance(item, dict)
+ ]
+ },
+ }
+
+
+def verify_immutable_policy(
+ *,
+ username: str,
+ password: str,
+ opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
+) -> None:
+ """Fail closed before build unless the exact Harbor rule is active."""
+ status, body, headers = _immutable_rules_response(
+ username=username, password=password, opener=opener
+ )
+ if status != 200:
+ raise RuntimeError(f"Harbor immutable policy preflight returned HTTP {status}")
+ try:
+ rules = json.loads(body.decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise RuntimeError("Harbor returned invalid immutable rule JSON") from exc
+ if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules):
+ raise RuntimeError("Harbor immutable rule list has an invalid shape")
+ _require_complete_rule_page(rules, headers)
+ expected = {
+ "disabled": False,
+ "action": "immutable",
+ "template": "immutable_template",
+ "tag_selectors": [
+ {
+ "kind": "doublestar",
+ "decoration": "matches",
+ "pattern": IMMUTABLE_TAG_PATTERN,
+ }
+ ],
+ "scope_selectors": {
+ "repository": [
+ {
+ "kind": "doublestar",
+ "decoration": "repoMatches",
+ "pattern": IMMUTABLE_REPOSITORY_PATTERN,
+ }
+ ]
+ },
+ }
+ matches = [
+ _normalized_immutable_rule(item)
+ for item in rules
+ if _normalized_immutable_rule(item)["tag_selectors"]
+ == expected["tag_selectors"]
+ and _normalized_immutable_rule(item)["scope_selectors"]
+ == expected["scope_selectors"]
+ ]
+ if matches != [expected]:
+ raise RuntimeError("Harbor immutable build-tag policy is absent or not exact")
+
+
+def assert_tag_absent(
+ destination: str,
+ *,
+ username: str,
+ password: str,
+ opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
+) -> None:
+ """Reject replay before Kaniko can push an already-used immutable identity."""
+ status, _body = _artifact_response(
+ destination, username=username, password=password, opener=opener
+ )
+ if status == 404:
+ return
+ if status == 200:
+ raise RuntimeError("Harbor destination tag already exists; refusing overwrite")
+ raise RuntimeError(f"Harbor destination preflight returned HTTP {status}")
+
+
+def verify_registry_digest(
+ destination: str,
+ digest: str,
+ *,
+ username: str,
+ password: str,
+ opener: Callable[[urllib.request.Request, int], Any] = _registry_request,
+) -> None:
+ """Verify Harbor independently resolves the pushed tag to Kaniko's digest."""
+ digest = _validated(digest, DIGEST_PATTERN, "image digest")
+ status, body = _artifact_response(
+ destination, username=username, password=password, opener=opener
+ )
+ if status != 200:
+ raise RuntimeError(f"Harbor manifest verification returned HTTP {status}")
+ try:
+ artifact = json.loads(body.decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise RuntimeError("Harbor returned invalid artifact JSON") from exc
+ harbor_digest = str(artifact.get("digest") or "").strip()
+ if not DIGEST_PATTERN.fullmatch(harbor_digest):
+ raise RuntimeError("Harbor response omitted a valid artifact digest")
+ if harbor_digest != digest:
+ raise RuntimeError("Harbor digest does not match Kaniko evidence")
+ expected_tag = destination.rsplit(":", 1)[1]
+ matching_tags = [
+ item
+ for item in artifact.get("tags") or []
+ if isinstance(item, dict) and item.get("name") == expected_tag
+ ]
+ if len(matching_tags) != 1:
+ raise RuntimeError("Harbor artifact does not contain the expected tag")
+ if matching_tags[0].get("immutable") is not True:
+ raise RuntimeError("Harbor did not enforce the expected tag as immutable")
+
+
+def validate_release_artifacts(
+ *,
+ digest_file: Path,
+ image_file: Path,
+ source_revision: str,
+ build_number: str,
+ destination: str,
+ chat_manifest: Path,
+ dashboard_manifest: Path,
+ output_dir: Path,
+) -> None:
+ """Revalidate the exact successful-build evidence without rewriting it."""
+ digest = validate_kaniko_evidence(
+ digest_text=digest_file.read_text(encoding="utf-8"),
+ image_text=image_file.read_text(encoding="utf-8"),
+ destination=destination,
+ )
+ validate_flux_release_artifacts(
+ digest=digest,
+ source_revision=source_revision,
+ build_number=build_number,
+ destination=destination,
+ chat_manifest=chat_manifest,
+ dashboard_manifest=dashboard_manifest,
+ output_dir=output_dir,
+ )
+
+
+def _credentials() -> tuple[str, str]:
+ """Read the masked, runtime-only Jenkins credential environment."""
+ username = os.environ.get("HARBOR_USER", "")
+ password = os.environ.get("HARBOR_PASSWORD", "")
+ if not username or not password:
+ raise RuntimeError("Harbor credentials are unavailable")
+ return username, password
+
+
+def _common_arguments(parser: argparse.ArgumentParser) -> None:
+ parser.add_argument("--source-revision", required=True)
+ parser.add_argument("--build-number", required=True)
+ parser.add_argument("--destination", required=True)
+
+
+def main() -> int:
+ """Fail closed around the unique tag, then verify and render after push."""
+ parser = argparse.ArgumentParser()
+ commands = parser.add_subparsers(dest="command", required=True)
+ absent = commands.add_parser("assert-absent")
+ _common_arguments(absent)
+ render = commands.add_parser("render")
+ _common_arguments(render)
+ render.add_argument("--digest-file", required=True, type=Path)
+ render.add_argument("--image-file", required=True, type=Path)
+ render.add_argument("--chat-manifest", required=True, type=Path)
+ render.add_argument("--dashboard-manifest", required=True, type=Path)
+ render.add_argument("--output-dir", required=True, type=Path)
+ verify = commands.add_parser("verify-evidence")
+ _common_arguments(verify)
+ verify.add_argument("--digest-file", required=True, type=Path)
+ verify.add_argument("--image-file", required=True, type=Path)
+ verify.add_argument("--chat-manifest", required=True, type=Path)
+ verify.add_argument("--dashboard-manifest", required=True, type=Path)
+ verify.add_argument("--output-dir", required=True, type=Path)
+ args = parser.parse_args()
+
+ validate_destination(args.destination, args.source_revision, args.build_number)
+ if args.command == "verify-evidence":
+ validate_release_artifacts(
+ digest_file=args.digest_file,
+ image_file=args.image_file,
+ source_revision=args.source_revision,
+ build_number=args.build_number,
+ destination=args.destination,
+ chat_manifest=args.chat_manifest,
+ dashboard_manifest=args.dashboard_manifest,
+ output_dir=args.output_dir,
+ )
+ return 0
+
+ username, password = _credentials()
+ if args.command == "assert-absent":
+ verify_immutable_policy(username=username, password=password)
+ assert_tag_absent(args.destination, username=username, password=password)
+ return 0
+
+ digest = validate_kaniko_evidence(
+ digest_text=args.digest_file.read_text(encoding="utf-8"),
+ image_text=args.image_file.read_text(encoding="utf-8"),
+ destination=args.destination,
+ )
+ verify_registry_digest(
+ args.destination, digest, username=username, password=password
+ )
+ write_release_artifacts(
+ digest=digest,
+ source_revision=args.source_revision,
+ build_number=args.build_number,
+ destination=args.destination,
+ chat_manifest=args.chat_manifest,
+ dashboard_manifest=args.dashboard_manifest,
+ output_dir=args.output_dir,
+ )
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover - exercised through main()
+ raise SystemExit(main())
diff --git a/clusters/atlas/flux-system/applications/harbor/kustomization.yaml b/clusters/atlas/flux-system/applications/harbor/kustomization.yaml
index 225214fe..a1782f58 100644
--- a/clusters/atlas/flux-system/applications/harbor/kustomization.yaml
+++ b/clusters/atlas/flux-system/applications/harbor/kustomization.yaml
@@ -22,6 +22,10 @@ spec:
kind: Job
name: harbor-hermes-agent-immutability-ensure-1
namespace: harbor
+ - apiVersion: batch/v1
+ kind: Job
+ name: harbor-hermes-webui-immutability-ensure-1
+ namespace: harbor
dependsOn:
- name: core
- name: longhorn
diff --git a/dockerfiles/Dockerfile.hermes-webui b/dockerfiles/Dockerfile.hermes-webui
index d6b83e98..2676844c 100644
--- a/dockerfiles/Dockerfile.hermes-webui
+++ b/dockerfiles/Dockerfile.hermes-webui
@@ -10,85 +10,9 @@ USER root
# while the gateway remains the only process that owns an agent conversation.
COPY --from=webui /apptoo /opt/hermes-webui
-# The account policy caps user-selected reasoning at xhigh even when a provider
-# advertises a newer, more expensive level.
-RUN /opt/hermes/.venv/bin/python - <<'PY'
-from pathlib import Path
-
-config = Path("/opt/hermes-webui/api/config.py")
-source = config.read_text(encoding="utf-8")
-before = 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh", "max")'
-after = 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")'
-if before not in source:
- raise SystemExit("Hermes WebUI reasoning-effort patch context changed")
-config.write_text(source.replace(before, after, 1), encoding="utf-8")
-
-index = Path("/opt/hermes-webui/static/index.html")
-source = index.read_text(encoding="utf-8")
-before = '
Max
\n'
-if before not in source:
- raise SystemExit("Hermes WebUI xhigh UI patch context changed")
-index.write_text(source.replace(before, "", 1), encoding="utf-8")
-
-# oauth2-proxy returns 401 for browser API and health probes when the secure
-# session expires. Re-enter OIDC with the complete return path instead of
-# presenting an endless, inaccurate "connection lost" loop.
-ui = Path("/opt/hermes-webui/static/ui.js")
-source = ui.read_text(encoding="utf-8")
-before = ''' const res=await fetcher(_offlineHealthUrl(),opts);
- return !!(res&&res.ok);
-'''
-after = ''' const res=await fetcher(_offlineHealthUrl(),opts);
- if(res&&(res.status===401||res.status===403)){
- const rd=window.location.pathname+window.location.search+window.location.hash;
- window.location.assign('/oauth2/start?rd='+encodeURIComponent(rd));
- return false;
- }
- return !!(res&&res.ok);
-'''
-if source.count(before) != 1:
- raise SystemExit("Hermes WebUI auth-recovery patch context changed")
-ui.write_text(source.replace(before, after, 1), encoding="utf-8")
-
-# Make delegated session hierarchy obvious and collapsible in the sidebar.
-sessions = Path("/opt/hermes-webui/static/sessions.js")
-source = sessions.read_text(encoding="utf-8")
-before = ''' const childLabel=t('session_meta_children', childCount);
- childCountEl.textContent=childLabel;
- childCountEl.title=_sessionChildBadgeTooltip(childLabel);
-'''
-after = ''' const childLabel=t('session_meta_children', childCount);
- const childrenExpanded=_expandedChildSessionKeys.has(lineageKey)||!!searchQueryRaw;
- childCountEl.textContent=(childrenExpanded?'▾ ':'▸ ')+childLabel;
- childCountEl.setAttribute('aria-expanded',childrenExpanded?'true':'false');
- childCountEl.title=_sessionChildBadgeTooltip(childLabel);
-'''
-if source.count(before) != 1:
- raise SystemExit("Hermes WebUI child-session toggle patch context changed")
-sessions.write_text(source.replace(before, after, 1), encoding="utf-8")
-
-# A profile's model is only its default; a session-level selector can override
-# it. Label the scope so the dropdown does not contradict the effective model.
-panels = Path("/opt/hermes-webui/static/panels.js")
-source = panels.read_text(encoding="utf-8")
-before = " if (typeof p.model === 'string' && p.model) meta.push(p.model.split('/').pop());\n"
-after = ''' if (typeof p.model === 'string' && p.model) {
- const routeLabels = {
- 'atlas/auto/fast': 'Automatic · Fast',
- 'atlas/auto/balanced': 'Automatic · Balanced',
- 'atlas/auto/deep': 'Automatic · Deep',
- 'atlas/auto/maximum': 'Automatic · Maximum',
- };
- meta.push('profile default: ' + (routeLabels[p.model] || p.model.split('/').pop()));
- }
-'''
-if source.count(before) != 2:
- raise SystemExit("Hermes WebUI profile-model label patch context changed")
-panels.write_text(source.replace(before, after, 2), encoding="utf-8")
-PY
-
# Add the Atlas voice bridge as a narrow integration layer. It activates only
# when a tenant's server-side STT capability reports the private Jetson route.
+COPY dockerfiles/hermes-webui-base-patch.py /tmp/hermes-webui-base-patch.py
COPY dockerfiles/hermes-webui-atlas-patch.py /tmp/hermes-webui-atlas-patch.py
COPY dockerfiles/hermes-webui-stt-patch.py /tmp/hermes-webui-stt-patch.py
COPY dockerfiles/hermes-webui-telegram-project-patch.py /tmp/hermes-webui-telegram-project-patch.py
@@ -96,15 +20,24 @@ COPY dockerfiles/hermes-webui-atlas-voice.js /opt/hermes-webui/static/atlas-voic
COPY dockerfiles/hermes-webui-atlas-voice.css /opt/hermes-webui/static/atlas-voice.css
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-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-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
+RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-base-patch.py
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-atlas-patch.py
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 -c 'import cryptography, yaml' \
&& grep -Fq 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")' \
/opt/hermes-webui/api/config.py \
&& ! grep -Fq 'data-effort="max"' /opt/hermes-webui/static/index.html \
+ && grep -Fq 'res.status===401||res.status===403' /opt/hermes-webui/static/ui.js \
&& grep -Fq "window.location.assign('/oauth2/start?rd='" /opt/hermes-webui/static/ui.js \
&& grep -Fq "childrenExpanded?'▾ ':'▸ '" /opt/hermes-webui/static/sessions.js \
&& grep -Fq "TELEGRAM_PROJECT_NAME = 'Telegram'" /opt/hermes-webui/api/models.py \
@@ -124,6 +57,24 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
&& grep -Fq 'def _atlas_tts_language(body):' /opt/hermes-webui/api/routes.py \
&& grep -Fq 'request_payload["language"] = _atlas_language' /opt/hermes-webui/api/routes.py \
&& grep -Fq 'takeSttLanguage(token)' /opt/hermes-webui/static/atlas-voice.js \
+ && grep -Fq 'Hermes Chat' /opt/hermes-webui/static/index.html \
+ && grep -Fq 'id="hermesBrandStyles"' /opt/hermes-webui/static/index.html \
+ && grep -Fq 'static/hermes-agent-512.png' /opt/hermes-webui/static/index.html \
+ && grep -Fq 'prefers-reduced-motion: reduce' /opt/hermes-webui/static/hermes-brand.css \
+ && grep -Fq '"name": "Hermes Chat"' /opt/hermes-webui/static/manifest.json \
+ && grep -Fq "'./static/hermes-agent-512.png'" /opt/hermes-webui/static/sw.js \
+ && printf '%s %s\n' \
+ 'aefe65e6574e6f46d3382588f46c508e1b3f3b3c9ce3dec6d335403a5374add9' \
+ '/opt/hermes-webui/static/hermes-agent.ico' \
+ | sha256sum -c - \
+ && printf '%s %s\n' \
+ '0e4102cc715372058dd6ab55e9cde2567e46fee5ab6557b564cdd212ccd2616f' \
+ '/opt/hermes-webui/static/hermes-agent-192.png' \
+ | sha256sum -c - \
+ && printf '%s %s\n' \
+ '6661e5ca0ecc690af213b9f85f961541e6d3d0e36946ede9d3c2c97dfbd3c23d' \
+ '/opt/hermes-webui/static/hermes-agent-512.png' \
+ | sha256sum -c - \
&& /opt/hermes/.venv/bin/python -m py_compile \
/opt/hermes-webui/api/routes.py \
/opt/hermes-webui/api/upload.py \
diff --git a/dockerfiles/hermes-webui-assets/SOURCE.md b/dockerfiles/hermes-webui-assets/SOURCE.md
new file mode 100644
index 00000000..3d68694f
--- /dev/null
+++ b/dockerfiles/hermes-webui-assets/SOURCE.md
@@ -0,0 +1,18 @@
+# Hermes WebUI persona icon provenance
+
+`hermes-agent.ico` is a byte-for-byte tracked copy of the canonical Hermes
+Agent dashboard icon from `/opt/hermes/web/public/favicon.ico`. The same bytes
+were independently present at `/opt/hermes/hermes_cli/web_dist/favicon.ico` on
+the Atlas coordinator when this asset was imported on 2026-08-23.
+
+- Canonical ICO SHA-256: `aefe65e6574e6f46d3382588f46c508e1b3f3b3c9ce3dec6d335403a5374add9`
+- ICO payloads: PNG-encoded RGBA variants at 16x16, 32x32, and 48x48
+- `hermes-agent-192.png`: 48px canonical variant resized to 192x192 with
+ Pillow 12.2.0 LANCZOS resampling; SHA-256
+ `0e4102cc715372058dd6ab55e9cde2567e46fee5ab6557b564cdd212ccd2616f`
+- `hermes-agent-512.png`: 48px canonical variant resized to 512x512 with
+ Pillow 12.2.0 LANCZOS resampling; SHA-256
+ `6661e5ca0ecc690af213b9f85f961541e6d3d0e36946ede9d3c2c97dfbd3c23d`
+
+The larger files are faithful format/size derivatives for PWA installation;
+they do not redraw or replace the supplied persona.
diff --git a/dockerfiles/hermes-webui-assets/hermes-agent-192.png b/dockerfiles/hermes-webui-assets/hermes-agent-192.png
new file mode 100644
index 00000000..2af94217
Binary files /dev/null and b/dockerfiles/hermes-webui-assets/hermes-agent-192.png differ
diff --git a/dockerfiles/hermes-webui-assets/hermes-agent-512.png b/dockerfiles/hermes-webui-assets/hermes-agent-512.png
new file mode 100644
index 00000000..dc68afba
Binary files /dev/null and b/dockerfiles/hermes-webui-assets/hermes-agent-512.png differ
diff --git a/dockerfiles/hermes-webui-assets/hermes-agent.ico b/dockerfiles/hermes-webui-assets/hermes-agent.ico
new file mode 100644
index 00000000..7a949324
Binary files /dev/null and b/dockerfiles/hermes-webui-assets/hermes-agent.ico differ
diff --git a/dockerfiles/hermes-webui-base-patch.py b/dockerfiles/hermes-webui-base-patch.py
new file mode 100644
index 00000000..1629d25a
--- /dev/null
+++ b/dockerfiles/hermes-webui-base-patch.py
@@ -0,0 +1,76 @@
+#!/usr/bin/env python3
+"""Apply Atlas baseline UI policy patches to the pinned Hermes WebUI."""
+
+from pathlib import Path
+
+
+config = Path("/opt/hermes-webui/api/config.py")
+source = config.read_text(encoding="utf-8")
+before = (
+ 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh", "max")'
+)
+after = 'VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")'
+if source.count(before) != 1:
+ raise SystemExit("Hermes WebUI reasoning-effort patch context changed")
+config.write_text(source.replace(before, after, 1), encoding="utf-8")
+
+index = Path("/opt/hermes-webui/static/index.html")
+source = index.read_text(encoding="utf-8")
+before = ' Max
\n'
+if source.count(before) != 1:
+ raise SystemExit("Hermes WebUI xhigh UI patch context changed")
+index.write_text(source.replace(before, "", 1), encoding="utf-8")
+
+# oauth2-proxy returns 401 for browser API and health probes when the secure
+# session expires. Re-enter OIDC with the complete return path.
+ui = Path("/opt/hermes-webui/static/ui.js")
+source = ui.read_text(encoding="utf-8")
+before = """ const res=await fetcher(_offlineHealthUrl(),opts);
+ return !!(res&&res.ok);
+"""
+after = """ const res=await fetcher(_offlineHealthUrl(),opts);
+ if(res&&(res.status===401||res.status===403)){
+ const rd=window.location.pathname+window.location.search+window.location.hash;
+ window.location.assign('/oauth2/start?rd='+encodeURIComponent(rd));
+ return false;
+ }
+ return !!(res&&res.ok);
+"""
+if source.count(before) != 1:
+ raise SystemExit("Hermes WebUI auth-recovery patch context changed")
+ui.write_text(source.replace(before, after, 1), encoding="utf-8")
+
+# Make delegated session hierarchy obvious and collapsible in the sidebar.
+sessions = Path("/opt/hermes-webui/static/sessions.js")
+source = sessions.read_text(encoding="utf-8")
+before = """ const childLabel=t('session_meta_children', childCount);
+ childCountEl.textContent=childLabel;
+ childCountEl.title=_sessionChildBadgeTooltip(childLabel);
+"""
+after = """ const childLabel=t('session_meta_children', childCount);
+ const childrenExpanded=_expandedChildSessionKeys.has(lineageKey)||!!searchQueryRaw;
+ childCountEl.textContent=(childrenExpanded?'▾ ':'▸ ')+childLabel;
+ childCountEl.setAttribute('aria-expanded',childrenExpanded?'true':'false');
+ childCountEl.title=_sessionChildBadgeTooltip(childLabel);
+"""
+if source.count(before) != 1:
+ raise SystemExit("Hermes WebUI child-session toggle patch context changed")
+sessions.write_text(source.replace(before, after, 1), encoding="utf-8")
+
+# A profile's model is only its default; label that scope in both render paths.
+panels = Path("/opt/hermes-webui/static/panels.js")
+source = panels.read_text(encoding="utf-8")
+before = " if (typeof p.model === 'string' && p.model) meta.push(p.model.split('/').pop());\n"
+after = """ if (typeof p.model === 'string' && p.model) {
+ const routeLabels = {
+ 'atlas/auto/fast': 'Automatic · Fast',
+ 'atlas/auto/balanced': 'Automatic · Balanced',
+ 'atlas/auto/deep': 'Automatic · Deep',
+ 'atlas/auto/maximum': 'Automatic · Maximum',
+ };
+ meta.push('profile default: ' + (routeLabels[p.model] || p.model.split('/').pop()));
+ }
+"""
+if source.count(before) != 2:
+ raise SystemExit("Hermes WebUI profile-model label patch context changed")
+panels.write_text(source.replace(before, after, 2), encoding="utf-8")
diff --git a/dockerfiles/hermes-webui-brand-patch.py b/dockerfiles/hermes-webui-brand-patch.py
new file mode 100644
index 00000000..2c3e8889
--- /dev/null
+++ b/dockerfiles/hermes-webui-brand-patch.py
@@ -0,0 +1,109 @@
+#!/usr/bin/env python3
+"""Apply fail-closed Hermes identity and PWA patches to pinned WebUI source."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+
+ROOT = Path(os.environ.get("HERMES_WEBUI_PATCH_ROOT", "/opt/hermes-webui"))
+
+
+def replace_exact(path: Path, before: str, after: str, count: int = 1) -> None:
+ """Replace one exact upstream fragment and reject pin drift."""
+ source = path.read_text(encoding="utf-8")
+ if source.count(before) != count:
+ raise SystemExit(
+ f"Hermes brand patch context changed in {path}: {before[:80]!r}"
+ )
+ path.write_text(source.replace(before, after, count), encoding="utf-8")
+
+
+def replace_between_exact(path: Path, start: str, end: str, after: str) -> None:
+ """Replace one uniquely bounded upstream region and reject ambiguous input."""
+ source = path.read_text(encoding="utf-8")
+ if source.count(start) != 1 or source.count(end) != 1:
+ raise SystemExit(
+ f"Hermes brand patch context changed in {path}: {start[:80]!r}"
+ )
+ start_index = source.index(start)
+ end_index = source.index(end, start_index)
+ path.write_text(source[:start_index] + after + source[end_index:], encoding="utf-8")
+
+
+index = ROOT / "static/index.html"
+replace_exact(index, "Hermes", "Hermes Chat")
+replace_exact(
+ index,
+ """
+
+""",
+ """
+
+""",
+)
+replace_exact(
+ index,
+ '',
+ '',
+)
+replace_exact(
+ index,
+ '',
+ '',
+)
+replace_exact(
+ index,
+ '',
+ '',
+)
+replace_exact(
+ index,
+ '',
+ '',
+)
+replace_exact(
+ index,
+ '',
+ '',
+)
+replace_exact(
+ index,
+ "var c=t==='dark'?'#141425':'#FAF7F0';",
+ "var c=t==='dark'?'#0D1420':'#E8F1F2';",
+)
+replace_exact(
+ index,
+ '',
+ '\n'
+ '',
+)
+replace_between_exact(
+ index,
+ ' \n',
+ ' Hermes',
+ ' \n'
+ '
\n'
+ " \n",
+)
+replace_exact(
+ index,
+ ' Hermes',
+ ' Hermes Chat',
+)
+
+service_worker = ROOT / "static/sw.js"
+replace_exact(
+ service_worker,
+ " './static/style.css' + VQ,\n",
+ " './static/style.css' + VQ,\n './static/hermes-brand.css' + VQ,\n",
+)
+replace_exact(
+ service_worker,
+ " './static/favicon.svg',\n './static/favicon-32.png',\n './manifest.json',\n",
+ " './static/hermes-agent.ico',\n"
+ " './static/hermes-agent-192.png',\n"
+ " './static/hermes-agent-512.png',\n"
+ " './manifest.json',\n",
+)
diff --git a/dockerfiles/hermes-webui-brand.css b/dockerfiles/hermes-webui-brand.css
new file mode 100644
index 00000000..ade9f906
--- /dev/null
+++ b/dockerfiles/hermes-webui-brand.css
@@ -0,0 +1,105 @@
+/* Restrained Hermes/Atlas identity layered after the pinned upstream theme. */
+:root {
+ --accent: #187f8b;
+ --accent-hover: #126a74;
+ --accent-bg: rgba(24, 127, 139, 0.09);
+ --accent-bg-strong: rgba(24, 127, 139, 0.17);
+ --accent-text: #126f7a;
+ --blue: #187f9f;
+ --gold: #9a661f;
+ --focus-ring: rgba(24, 127, 139, 0.38);
+ --focus-glow: rgba(24, 127, 139, 0.12);
+}
+
+:root.dark {
+ color-scheme: dark;
+ --bg: #070a12;
+ --sidebar: #0d1420;
+ --surface: #111b29;
+ --surface-subtle: rgba(116, 202, 214, 0.035);
+ --surface-subtle-hover: rgba(116, 202, 214, 0.075);
+ --border: #203044;
+ --border2: rgba(174, 218, 224, 0.18);
+ --border-subtle: rgba(174, 218, 224, 0.08);
+ --border-muted: rgba(174, 218, 224, 0.13);
+ --text: #e8f1f4;
+ --strong: #f8fcfd;
+ --muted: #91a5b3;
+ --em: #c5d2d8;
+ --accent: #48cfcc;
+ --accent-hover: #75dedb;
+ --accent-bg: rgba(72, 207, 204, 0.09);
+ --accent-bg-strong: rgba(72, 207, 204, 0.17);
+ --accent-text: #6bd8d4;
+ --blue: #4ca4cd;
+ --gold: #f0b66b;
+ --code-bg: #09111c;
+ --code-inline-bg: rgba(4, 10, 17, 0.72);
+ --code-text: #b9e5e4;
+ --pre-text: #dce8ec;
+ --input-bg: rgba(193, 229, 233, 0.045);
+ --hover-bg: rgba(193, 229, 233, 0.07);
+ --topbar-bg: rgba(9, 14, 24, 0.96);
+ --main-bg: rgba(7, 10, 18, 0.72);
+ --focus-ring: rgba(72, 207, 204, 0.38);
+ --focus-glow: rgba(72, 207, 204, 0.12);
+ --error: #f08b79;
+ --success: #65c9a6;
+ --warning: #f0b66b;
+ --info: #69b9dc;
+}
+
+:root.dark body {
+ background:
+ radial-gradient(circle at 78% 8%, rgba(72, 164, 205, 0.07), transparent 31rem),
+ linear-gradient(145deg, #070a12, #080d17 55%, #071017);
+}
+
+:root.dark .app-titlebar,
+:root.dark .rail,
+:root.dark .sidebar,
+:root.dark .rightpanel,
+:root.dark .topbar,
+:root.dark .composer-wrap {
+ border-color: var(--border);
+ background-color: rgba(13, 20, 32, 0.94);
+}
+
+.app-titlebar-icon img {
+ display: block;
+ width: 22px;
+ height: 22px;
+ border: 1px solid rgba(72, 207, 204, 0.24);
+ border-radius: 7px;
+ box-shadow: 0 0 0 2px rgba(72, 207, 204, 0.05);
+}
+
+.app-titlebar-title {
+ letter-spacing: 0.025em;
+}
+
+:root.dark .composer-box:focus-within {
+ border-color: rgba(72, 207, 204, 0.66);
+ box-shadow: 0 0 0 2px var(--focus-glow), 0 10px 34px rgba(0, 0, 0, 0.2);
+}
+
+/* Keep the conversation instrument inside the same cyan/blue/gold family. */
+:root.dark .voice-mode-bar {
+ --voice-accent: 72, 207, 204;
+ --voice-accent-secondary: 76, 164, 205;
+ border-bottom-color: rgba(174, 218, 224, 0.11);
+ background:
+ radial-gradient(circle at 50% 38%, rgba(var(--voice-accent), 0.085), transparent 47%),
+ linear-gradient(180deg, rgba(17, 27, 41, 0.8), rgba(7, 10, 18, 0.35));
+}
+
+@media (prefers-reduced-motion: reduce) {
+ :root.dark body {
+ background: #070a12;
+ }
+
+ .app-titlebar-icon img,
+ :root.dark .composer-box:focus-within {
+ transition: none !important;
+ }
+}
diff --git a/dockerfiles/hermes-webui-manifest.json b/dockerfiles/hermes-webui-manifest.json
new file mode 100644
index 00000000..1c84e804
--- /dev/null
+++ b/dockerfiles/hermes-webui-manifest.json
@@ -0,0 +1,43 @@
+{
+ "id": "./",
+ "name": "Hermes Chat",
+ "short_name": "Hermes",
+ "description": "Private Hermes Agent chat on Atlas",
+ "start_url": "./?source=pwa",
+ "scope": "./",
+ "display": "standalone",
+ "display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
+ "background_color": "#070A12",
+ "theme_color": "#0D1420",
+ "orientation": "any",
+ "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/hermes-agent-192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ }
+ ]
+ }
+ ],
+ "icons": [
+ {
+ "src": "static/hermes-agent-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "static/hermes-agent-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any"
+ }
+ ]
+}
diff --git a/docs/hermes_webui_release.md b/docs/hermes_webui_release.md
new file mode 100644
index 00000000..061f8cb6
--- /dev/null
+++ b/docs/hermes_webui_release.md
@@ -0,0 +1,39 @@
+# Hermes WebUI release lane
+
+Hermes WebUI has a release lane separate from `hermes-agent-image`. The lane
+builds `dockerfiles/Dockerfile.hermes-webui` from one exact reviewed `main`
+commit, publishes a unique immutable Harbor tag, independently verifies the
+Harbor digest, and renders a review-only Flux patch. It never writes Git and it
+never reconciles or restarts a workload.
+
+## Release sequence
+
+1. Merge and review all WebUI source, patch, theme, and PWA asset changes.
+2. Wait for Flux to complete both Harbor immutability Jobs and refresh Jenkins
+ JCasC from reviewed `main`.
+3. Open the manual Jenkins job `hermes-webui-image` and set:
+ - `PUBLISH_IMAGE=true`
+ - `EXPECTED_SOURCE_REVISION` to the full 40-character `main` commit
+ - `CONFIRM_PUBLISH=PUBLISH HERMES WEBUI`
+4. Retain the fingerprinted seven-file artifact set. In particular, compare
+ `hermes-webui-image.json` with Harbor and review
+ `hermes-webui-image-update.patch`.
+5. Apply that patch on a fresh branch and open a separate review. The patch is
+ constrained to the `webui` container in:
+ - `StatefulSet/hermes-chat-tenant`
+ - `Deployment/hermes`
+6. Merge the digest-only review to let Flux roll out desired state. Do not use a
+ manual `kubectl set image`, restart, or reconcile as a release substitute.
+
+The release fails closed when the requested revision is not the checked-out
+`origin/main`, the unique Harbor tag already exists, the exact WebUI immutable
+tag policy is absent, Kaniko and Harbor disagree on the digest, either Flux
+workload changes identity/image shape, or the evidence archive is incomplete.
+
+## PWA identity source
+
+The installed application uses the tracked canonical persona at
+`dockerfiles/hermes-webui-assets/hermes-agent.ico`. Provenance, the canonical
+SHA-256, and derivation details for the required 192px/512px PNGs are recorded
+beside the asset in `SOURCE.md`; the image build never reads an icon from a
+running coordinator.
diff --git a/services/harbor/hermes-webui-immutability-job.yaml b/services/harbor/hermes-webui-immutability-job.yaml
new file mode 100644
index 00000000..b3667380
--- /dev/null
+++ b/services/harbor/hermes-webui-immutability-job.yaml
@@ -0,0 +1,79 @@
+# services/harbor/hermes-webui-immutability-job.yaml
+apiVersion: batch/v1
+kind: Job
+metadata:
+ name: harbor-hermes-webui-immutability-ensure-1
+ namespace: harbor
+spec:
+ backoffLimit: 2
+ activeDeadlineSeconds: 600
+ template:
+ metadata:
+ annotations:
+ vault.hashicorp.com/agent-inject: "true"
+ vault.hashicorp.com/agent-pre-populate-only: "true"
+ vault.hashicorp.com/agent-run-as-user: "65532"
+ vault.hashicorp.com/agent-run-as-group: "65532"
+ vault.hashicorp.com/role: harbor-policy-bootstrap
+ vault.hashicorp.com/agent-inject-secret-harbor-admin-password: kv/data/atlas/harbor/harbor-core
+ vault.hashicorp.com/agent-inject-template-harbor-admin-password: |
+ {{- with secret "kv/data/atlas/harbor/harbor-core" -}}
+ {{ .Data.data.harbor_admin_password }}
+ {{- end -}}
+ spec:
+ serviceAccountName: harbor-policy-bootstrap
+ enableServiceLinks: false
+ restartPolicy: Never
+ nodeSelector:
+ hardware: rpi5
+ kubernetes.io/arch: arm64
+ node-role.kubernetes.io/worker: "true"
+ affinity:
+ nodeAffinity:
+ requiredDuringSchedulingIgnoredDuringExecution:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: NotIn
+ values: [titan-04, titan-14, titan-18, titan-19, titan-24]
+ securityContext:
+ fsGroup: 65532
+ fsGroupChangePolicy: OnRootMismatch
+ seccompProfile:
+ type: RuntimeDefault
+ containers:
+ - name: ensure
+ image: docker.io/library/python@sha256:efcdfa6a6b2fd2afb9c7dfa9a5b288a6f68338b5cfdebe6b637d986067d85757
+ imagePullPolicy: IfNotPresent
+ command: [python3, /scripts/harbor_hermes_webui_immutability_ensure.py]
+ env:
+ - name: HARBOR_API_ORIGIN
+ value: https://registry.bstein.dev/api/v2.0
+ - name: HARBOR_ADMIN_PASSWORD_FILE
+ value: /vault/secrets/harbor-admin-password
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop: ["ALL"]
+ readOnlyRootFilesystem: true
+ runAsGroup: 65532
+ runAsNonRoot: true
+ runAsUser: 65532
+ seccompProfile:
+ type: RuntimeDefault
+ volumeMounts:
+ - name: scripts
+ mountPath: /scripts
+ readOnly: true
+ - name: tmp
+ mountPath: /tmp
+ resources:
+ requests: {cpu: 25m, memory: 32Mi}
+ limits: {cpu: 250m, memory: 128Mi}
+ volumes:
+ - name: scripts
+ configMap:
+ name: harbor-hermes-webui-immutability-script
+ defaultMode: 0555
+ - name: tmp
+ emptyDir: {}
diff --git a/services/harbor/kustomization.yaml b/services/harbor/kustomization.yaml
index 7784bb50..750e808d 100644
--- a/services/harbor/kustomization.yaml
+++ b/services/harbor/kustomization.yaml
@@ -14,6 +14,7 @@ resources:
- vault-sync-deployment.yaml
- policy-bootstrap-serviceaccount.yaml
- hermes-agent-immutability-job.yaml
+ - hermes-webui-immutability-job.yaml
- bootstrap-jobs/cassandra-registry-ensure-job.yaml
- image.yaml
configMapGenerator:
@@ -23,3 +24,6 @@ configMapGenerator:
- name: harbor-hermes-agent-immutability-script
files:
- harbor_hermes_agent_immutability_ensure.py=scripts/harbor_hermes_agent_immutability_ensure.py
+ - name: harbor-hermes-webui-immutability-script
+ files:
+ - harbor_hermes_webui_immutability_ensure.py=scripts/harbor_hermes_webui_immutability_ensure.py
diff --git a/services/harbor/scripts/harbor_hermes_webui_immutability_ensure.py b/services/harbor/scripts/harbor_hermes_webui_immutability_ensure.py
new file mode 100644
index 00000000..a61ca788
--- /dev/null
+++ b/services/harbor/scripts/harbor_hermes_webui_immutability_ensure.py
@@ -0,0 +1,222 @@
+#!/usr/bin/env python3
+"""Create and verify the narrowly scoped Hermes WebUI immutable-tag rule."""
+
+from __future__ import annotations
+
+import base64
+import json
+import os
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from pathlib import Path
+from typing import Any
+
+
+PROJECT = "bstein"
+REPOSITORY_PATTERN = "hermes-webui"
+TAG_PATTERN = "git-*-build-*"
+EXPECTED_ORIGIN = "https://registry.bstein.dev/api/v2.0"
+MAX_RESPONSE = 1_048_576
+TRANSIENT_STATUSES = {429, 502, 503, 504}
+EXPECTED_RULE = {
+ "disabled": False,
+ "action": "immutable",
+ "template": "immutable_template",
+ "tag_selectors": [
+ {
+ "kind": "doublestar",
+ "decoration": "matches",
+ "pattern": TAG_PATTERN,
+ }
+ ],
+ "scope_selectors": {
+ "repository": [
+ {
+ "kind": "doublestar",
+ "decoration": "repoMatches",
+ "pattern": REPOSITORY_PATTERN,
+ }
+ ]
+ },
+}
+
+
+class NoRedirect(urllib.request.HTTPRedirectHandler):
+ """Prevent Basic credentials from following an unexpected redirect."""
+
+ def redirect_request(self, _request, _file, _code, _message, _headers, _url):
+ return None
+
+
+class HarborUnavailable(RuntimeError):
+ """Harbor is not ready yet, rather than returning a policy decision."""
+
+
+def normalized_rule(rule: dict[str, Any]) -> dict[str, Any]:
+ """Return only the immutable contract fields Harbor must preserve."""
+ return {
+ "disabled": bool(rule.get("disabled", False)),
+ "action": rule.get("action"),
+ "template": rule.get("template"),
+ "tag_selectors": [
+ {
+ "kind": selector.get("kind"),
+ "decoration": selector.get("decoration"),
+ "pattern": selector.get("pattern"),
+ }
+ for selector in rule.get("tag_selectors") or []
+ if isinstance(selector, dict)
+ ],
+ "scope_selectors": {
+ "repository": [
+ {
+ "kind": selector.get("kind"),
+ "decoration": selector.get("decoration"),
+ "pattern": selector.get("pattern"),
+ }
+ for selector in (rule.get("scope_selectors") or {}).get(
+ "repository", []
+ )
+ if isinstance(selector, dict)
+ ]
+ },
+ }
+
+
+def targets_webui_builds(rule: dict[str, Any]) -> bool:
+ """Detect a rule that claims this exact repository and tag selector."""
+ normalized = normalized_rule(rule)
+ return (
+ normalized["tag_selectors"] == EXPECTED_RULE["tag_selectors"]
+ and normalized["scope_selectors"] == EXPECTED_RULE["scope_selectors"]
+ )
+
+
+class HarborClient:
+ """Bounded same-origin client for Harbor's immutable-tag API."""
+
+ def __init__(self, origin: str, username: str, password: str) -> None:
+ normalized_origin = origin.rstrip("/")
+ if normalized_origin != EXPECTED_ORIGIN:
+ raise ValueError("Harbor API origin is not the pinned production API")
+ self.origin = normalized_origin
+ token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
+ self.headers = {"Authorization": f"Basic {token}"}
+ self.opener = urllib.request.build_opener(NoRedirect())
+
+ def request(
+ self, method: str, path: str, payload: dict[str, Any] | None = None
+ ) -> tuple[int, bytes, dict[str, str]]:
+ """Issue one request, returning even non-2xx responses for strict checks."""
+ data = None
+ headers = dict(self.headers)
+ if payload is not None:
+ data = json.dumps(payload, separators=(",", ":")).encode()
+ headers["Content-Type"] = "application/json"
+ request = urllib.request.Request(
+ f"{self.origin}{path}", data=data, headers=headers, method=method
+ )
+ try:
+ response = self.opener.open(request, timeout=20)
+ except urllib.error.HTTPError as exc:
+ response = exc
+ except (urllib.error.URLError, TimeoutError) as exc:
+ raise HarborUnavailable("Harbor policy API is unavailable") from exc
+ with response:
+ body = response.read(MAX_RESPONSE + 1)
+ if len(body) > MAX_RESPONSE:
+ raise RuntimeError("Harbor response exceeded the size limit")
+ return int(response.status), body, dict(response.headers)
+
+
+def list_rules(client: HarborClient) -> list[dict[str, Any]]:
+ """Read and validate the complete small rule set for the project."""
+ path = f"/projects/{PROJECT}/immutabletagrules?page=1&page_size=100"
+ status, body, headers = client.request("GET", path)
+ if status in TRANSIENT_STATUSES:
+ raise HarborUnavailable(f"Harbor immutable rule list returned HTTP {status}")
+ if status != 200:
+ raise RuntimeError(f"Harbor immutable rule list returned HTTP {status}")
+ try:
+ values = json.loads(body.decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise RuntimeError("Harbor returned invalid immutable rule JSON") from exc
+ if not isinstance(values, list) or not all(
+ isinstance(item, dict) for item in values
+ ):
+ raise RuntimeError("Harbor immutable rule list has an invalid shape")
+ raw_total = next(
+ (value for key, value in headers.items() if key.lower() == "x-total-count"),
+ None,
+ )
+ if raw_total is None or not str(raw_total).isdecimal():
+ raise RuntimeError("Harbor immutable rule list omitted a valid total count")
+ if int(raw_total) != len(values):
+ raise RuntimeError("Harbor immutable rule list was truncated")
+ return values
+
+
+def ensure_rule(client: HarborClient) -> int:
+ """Create once, or validate the one exact enabled WebUI rule."""
+ rules = list_rules(client)
+ matches = [rule for rule in rules if targets_webui_builds(rule)]
+ if len(matches) > 1:
+ raise RuntimeError("multiple Hermes WebUI immutable rules exist")
+ if matches:
+ if normalized_rule(matches[0]) != EXPECTED_RULE:
+ raise RuntimeError("Hermes WebUI immutable rule is not enabled and exact")
+ rule_id = matches[0].get("id")
+ if not isinstance(rule_id, int) or rule_id < 1:
+ raise RuntimeError("Harbor immutable rule omitted a valid ID")
+ return rule_id
+
+ path = f"/projects/{PROJECT}/immutabletagrules"
+ status, _body, headers = client.request("POST", path, EXPECTED_RULE)
+ if status in TRANSIENT_STATUSES:
+ raise HarborUnavailable(f"Harbor immutable rule create returned HTTP {status}")
+ if status != 201:
+ raise RuntimeError(f"Harbor immutable rule create returned HTTP {status}")
+ location = headers.get("Location") or headers.get("location") or ""
+ api_path = urllib.parse.urlsplit(client.origin).path.rstrip("/")
+ expected_prefix = f"{api_path}{path}/"
+ if not location.startswith(expected_prefix):
+ raise RuntimeError("Harbor immutable rule create omitted the exact Location")
+ suffix = location[len(expected_prefix) :]
+ if not suffix.isdecimal() or int(suffix) < 1:
+ raise RuntimeError("Harbor immutable rule Location has an invalid ID")
+
+ for attempt in range(1, 6):
+ matches = [rule for rule in list_rules(client) if targets_webui_builds(rule)]
+ if len(matches) == 1 and normalized_rule(matches[0]) == EXPECTED_RULE:
+ rule_id = matches[0].get("id")
+ if rule_id == int(suffix):
+ return rule_id
+ if attempt < 5:
+ time.sleep(attempt)
+ raise RuntimeError("created Harbor immutable rule did not verify exactly")
+
+
+def main() -> int:
+ """Load the runtime-only admin credential and enforce tracked policy."""
+ origin = os.environ.get("HARBOR_API_ORIGIN", "")
+ password_file = Path(os.environ.get("HARBOR_ADMIN_PASSWORD_FILE", ""))
+ password = password_file.read_text(encoding="utf-8").strip()
+ if not password:
+ raise RuntimeError("Harbor admin password is empty")
+ client = HarborClient(origin, "admin", password)
+ for attempt in range(1, 13):
+ try:
+ rule_id = ensure_rule(client)
+ break
+ except HarborUnavailable:
+ if attempt == 12:
+ raise
+ time.sleep(min(attempt * 2, 15))
+ print(f"Hermes WebUI immutable build-tag rule is active (id={rule_id})")
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover - exercised through main()
+ raise SystemExit(main())
diff --git a/services/jenkins/configmap-jcasc.yaml b/services/jenkins/configmap-jcasc.yaml
index b49fa0cc..fcb87234 100644
--- a/services/jenkins/configmap-jcasc.yaml
+++ b/services/jenkins/configmap-jcasc.yaml
@@ -671,6 +671,24 @@ data:
}
}
}
+ pipelineJob('hermes-webui-image') {
+ disabled(false)
+ description('Human-gated, daemonless Kaniko build for the reviewed atlas/titan-iac main revision. Publishes an immutable Hermes WebUI image and archives a narrow two-workload Flux digest patch; it never mutates Git or deploys.')
+ definition {
+ cpsScm {
+ scm {
+ git {
+ remote {
+ url('https://scm.bstein.dev/atlas/titan-iac.git')
+ credentials('gitea-pat')
+ }
+ branches('*/main')
+ }
+ }
+ scriptPath('ci/Jenkinsfile.hermes-webui-image')
+ }
+ }
+ }
multibranchPipelineJob('titan-iac-quality-gate') {
branchSources {
branchSource {
diff --git a/testing/fixtures/hermes-webui-0.52.181/static/index.html b/testing/fixtures/hermes-webui-0.52.181/static/index.html
index b8ff3aca..6d3bbf9f 100644
--- a/testing/fixtures/hermes-webui-0.52.181/static/index.html
+++ b/testing/fixtures/hermes-webui-0.52.181/static/index.html
@@ -1,9 +1,28 @@
+Hermes
+
+
+
+
+
+
+
+
+
+
+