diff --git a/clusters/atlas/flux-system/applications/hermes-chat/kustomization.yaml b/clusters/atlas/flux-system/applications/hermes-chat/kustomization.yaml
index 38b6d761e..64047c84e 100644
--- a/clusters/atlas/flux-system/applications/hermes-chat/kustomization.yaml
+++ b/clusters/atlas/flux-system/applications/hermes-chat/kustomization.yaml
@@ -15,14 +15,7 @@ spec:
namespace: flux-system
wait: true
timeout: 30m
- healthChecks:
- - apiVersion: apps/v1
- kind: Deployment
- name: hermes-chat
- namespace: hermes-chat
dependsOn:
- - name: cert-manager
- name: core
- name: hermes
- - name: keycloak
- name: longhorn
diff --git a/clusters/atlas/flux-system/applications/hermes/kustomization.yaml b/clusters/atlas/flux-system/applications/hermes/kustomization.yaml
index 47cca710b..155718554 100644
--- a/clusters/atlas/flux-system/applications/hermes/kustomization.yaml
+++ b/clusters/atlas/flux-system/applications/hermes/kustomization.yaml
@@ -32,10 +32,31 @@ spec:
namespace: hermes
- apiVersion: apps/v1
kind: Deployment
- name: oauth2-proxy-hermes
+ name: hermes-agent
+ namespace: hermes
+ - apiVersion: apps/v1
+ kind: StatefulSet
+ name: hermes-chat-tenant
+ namespace: hermes
+ - apiVersion: apps/v1
+ kind: Deployment
+ name: hermes-chat-router
+ namespace: hermes
+ - apiVersion: apps/v1
+ kind: Deployment
+ name: oauth2-proxy-hermes-agent
+ namespace: hermes
+ - apiVersion: apps/v1
+ kind: Deployment
+ name: oauth2-proxy-hermes-chat
+ namespace: hermes
+ - apiVersion: apps/v1
+ kind: Deployment
+ name: oauth2-proxy-hermes-triage
namespace: hermes
dependsOn:
- name: cert-manager
- name: core
- name: keycloak
- name: longhorn
+ - name: vault
diff --git a/dockerfiles/Dockerfile.hermes-webui b/dockerfiles/Dockerfile.hermes-webui
new file mode 100644
index 000000000..338ed40de
--- /dev/null
+++ b/dockerfiles/Dockerfile.hermes-webui
@@ -0,0 +1,75 @@
+# syntax=docker/dockerfile:1
+# dockerfiles/Dockerfile.hermes-webui
+FROM ghcr.io/nesquena/hermes-webui@sha256:a83a3893111dcb250e7aa7aa657d3d6f4570b0e2fd00d9b7569246fc5e7339b2 AS webui
+
+FROM registry.bstein.dev/bstein/hermes-agent@sha256:15c5c538c0b58686af2e54e10bc870b23284789d485a609349df24ed3053622f
+
+USER root
+
+# Keep WebUI and Hermes pinned together. The WebUI imports Hermes internals,
+# 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")
+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
+
+# Exercise the real server process in the target architecture before publish.
+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 \
+ HOME=/tmp/hermes-webui-smoke/home \
+ HERMES_WEBUI_STATE_DIR=/tmp/hermes-webui-smoke/state \
+ HERMES_WEBUI_DEFAULT_WORKSPACE=/tmp/hermes-webui-smoke/workspace \
+ HERMES_WEBUI_HOST=127.0.0.1 \
+ HERMES_WEBUI_PORT=18787 \
+ HERMES_WEBUI_SKIP_ONBOARDING=1 \
+ /opt/hermes/.venv/bin/python /opt/hermes-webui/server.py >/tmp/hermes-webui-smoke.log 2>&1 & \
+ server_pid=$!; \
+ 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; \
+ rm -rf /tmp/hermes-webui-smoke /tmp/hermes-webui-smoke.log
+
+ENV HERMES_WEBUI_AGENT_DIR=/opt/hermes \
+ HERMES_WEBUI_HOST=0.0.0.0 \
+ HERMES_WEBUI_PORT=8787 \
+ HERMES_WEBUI_CHAT_BACKEND=gateway \
+ HERMES_WEBUI_GATEWAY_BASE_URL=http://127.0.0.1:8642 \
+ HERMES_WEBUI_GATEWAY_USE_RUNS_API=true \
+ HERMES_WEBUI_SKIP_ONBOARDING=1 \
+ HERMES_WEBUI_SECURE=1 \
+ PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1
+
+WORKDIR /opt/hermes-webui
+USER 10000:10000
+EXPOSE 8787
+ENTRYPOINT ["/opt/hermes/.venv/bin/python", "/opt/hermes-webui/server.py"]
diff --git a/infrastructure/core/coredns-custom.yaml b/infrastructure/core/coredns-custom.yaml
index 3011dc4ae..beda2f43d 100644
--- a/infrastructure/core/coredns-custom.yaml
+++ b/infrastructure/core/coredns-custom.yaml
@@ -10,7 +10,7 @@ data:
errors
cache 30
hosts {
- 192.168.22.9 agent.bstein.dev
+ 192.168.22.9 agent.hermes.bstein.dev
192.168.22.9 alerts.bstein.dev
192.168.22.9 auth.bstein.dev
192.168.22.9 bstein.dev
@@ -18,6 +18,7 @@ data:
192.168.22.9 call.live.bstein.dev
192.168.22.9 cd.bstein.dev
192.168.22.9 chat.ai.bstein.dev
+ 192.168.22.9 chat.hermes.bstein.dev
192.168.22.9 ci.bstein.dev
192.168.22.9 cloud.bstein.dev
192.168.22.9 health.bstein.dev
@@ -44,6 +45,7 @@ data:
192.168.22.9 stream.bstein.dev
192.168.22.9 wolf.bstein.dev
192.168.22.9 tasks.bstein.dev
+ 192.168.22.9 triage.hermes.bstein.dev
192.168.22.9 vault.bstein.dev
fallthrough
}
diff --git a/services/hermes-chat/certificate.yaml b/services/hermes-chat/certificate.yaml
deleted file mode 100644
index 3bed14a1f..000000000
--- a/services/hermes-chat/certificate.yaml
+++ /dev/null
@@ -1,13 +0,0 @@
-# services/hermes-chat/certificate.yaml
-apiVersion: cert-manager.io/v1
-kind: Certificate
-metadata:
- name: chat-tls
- namespace: hermes-chat
-spec:
- secretName: chat-tls
- issuerRef:
- kind: ClusterIssuer
- name: letsencrypt
- dnsNames:
- - chat.bstein.dev
diff --git a/services/hermes-chat/configmap.yaml b/services/hermes-chat/configmap.yaml
deleted file mode 100644
index 84d6ab63c..000000000
--- a/services/hermes-chat/configmap.yaml
+++ /dev/null
@@ -1,153 +0,0 @@
-# services/hermes-chat/configmap.yaml
-apiVersion: v1
-kind: ConfigMap
-metadata:
- name: hermes-chat-config
- namespace: hermes-chat
- labels:
- app: hermes-chat
-data:
- config.yaml: |
- model:
- provider: openai-codex
- default: gpt-5.6-terra
- model: gpt-5.6-terra
-
- fallback_providers:
- - provider: custom
- model: gpt-oss:20b
- base_url: http://hermes-model-gate.hermes.svc.cluster.local:11434/v1
- api_key: ollama
-
- agent:
- api_max_retries: 1
-
- platform_toolsets:
- cli:
- - clarify
- - file
- - terminal
- - web
- api_server:
- - clarify
- - file
- - terminal
- - web
-
- skills:
- creation_nudge_interval: 15
- external_dirs:
- - /opt/data/workspace/skills
-
- terminal:
- backend: local
- cwd: /opt/data/workspace
- timeout: 180
- home_mode: profile
-
- approvals:
- mode: manual
- deny:
- - "*kubectl apply*"
- - "*kubectl create*"
- - "*kubectl delete*"
- - "*kubectl edit*"
- - "*kubectl patch*"
- - "*kubectl replace*"
- - "*kubectl scale*"
- - "*kubectl set*"
- - "*kubectl label*"
- - "*kubectl annotate*"
- - "*kubectl cordon*"
- - "*kubectl uncordon*"
- - "*kubectl drain*"
- - "*kubectl rollout restart*"
- - "*kubectl rollout undo*"
- - "*kubectl exec*"
- - "*kubectl attach*"
- - "*kubectl cp*"
- - "*kubectl debug*"
- - "*kubectl expose*"
- - "*kubectl port-forward*"
- - "*kubectl proxy*"
- - "*kubectl run*"
- - "*kubectl get secret*"
- - "*kubectl describe secret*"
- - "*flux reconcile*"
- - "*flux suspend*"
- - "*flux resume*"
- - "*vault *"
- - "*k3s *"
- - "*crictl *"
- - "*ctr *"
- - "*169.254.169.254*"
- - "*kubernetes.default*"
- - "*/var/run/secrets/kubernetes.io*"
-
- dashboard:
- public_url: https://chat.bstein.dev
- oauth:
- provider: self-hosted
- self_hosted:
- issuer: https://sso.bstein.dev/realms/atlas
- client_id: hermes-chat-dashboard
- scopes: openid profile email groups
-
- display:
- compact: true
- tool_progress: all
- interim_assistant_messages: true
- long_running_notifications: true
-
- tool_loop_guardrails:
- warnings_enabled: true
- hard_stop_enabled: true
- warn_after:
- exact_failure: 2
- same_tool_failure: 3
- idempotent_no_progress: 2
- hard_stop_after:
- exact_failure: 5
- same_tool_failure: 8
- idempotent_no_progress: 5
-
- updates:
- pre_update_backup: quick
- backup_keep: 3
- non_interactive_local_changes: stash
- SOUL.md: |
- You are a personal AI assistant and researcher for Atlas users. Be useful,
- curious, careful, and direct. Help with research, writing, planning,
- learning, files, personal automation, and creative work. Use tools when
- they materially improve the answer and make reusable skills when a stable
- workflow is worth keeping.
-
- Your environment is a private consumer sandbox. Work in the mounted
- workspace and with public Internet services the user intentionally asks
- you to use. You may inspect the Titan cluster through the dedicated
- read-only Kubernetes identity to explain health and status. You do not
- administer it: never attempt mutations, secret access, exec, attach,
- port-forwarding, private-service access, or cloud metadata access.
- AGENTS.md: |
- # Personal Hermes workspace
-
- This Hermes instance is a personal chat, research, and automation
- environment. User-created files and skills belong under `/opt/data/workspace`.
-
- You may:
-
- - research public Internet sources and cite them
- - create, read, and edit files in the workspace
- - create and improve reusable skills
- - configure user-owned channels and provider integrations
- - run ordinary local commands needed for the user's task
- - inspect non-secret Kubernetes resources and pod logs with read-only
- `kubectl get`, `describe`, and `logs` commands
-
- This container is not an infrastructure administration environment. The
- Kubernetes identity is observation-only and cannot read Secrets or use pod
- exec, attach, or port-forwarding. Do not attempt cluster mutations, private
- service access, node LAN access, metadata services, Vault, container
- runtimes, or the operator Hermes instance. If a request needs an action,
- explain the evidence and say Brad must perform it from the operator
- instance at `agent.bstein.dev`.
diff --git a/services/hermes-chat/deployment.yaml b/services/hermes-chat/deployment.yaml
deleted file mode 100644
index ac97e8bb4..000000000
--- a/services/hermes-chat/deployment.yaml
+++ /dev/null
@@ -1,259 +0,0 @@
-# services/hermes-chat/deployment.yaml
-apiVersion: apps/v1
-kind: Deployment
-metadata:
- name: hermes-chat
- namespace: hermes-chat
- labels:
- app: hermes-chat
-spec:
- replicas: 1
- revisionHistoryLimit: 2
- progressDeadlineSeconds: 1800
- strategy:
- type: Recreate
- selector:
- matchLabels:
- app: hermes-chat
- template:
- metadata:
- labels:
- app: hermes-chat
- annotations:
- ai.bstein.dev/model: openai-codex/gpt-5.6-terra with local gpt-oss:20b fallback
- ai.bstein.dev/role: personal-chat-research
- ai.bstein.dev/isolation: observer-only Kubernetes RBAC, private-service egress denied
- ai.bstein.dev/session-reset: "20260802-consumer-pty-recovery"
- ai.bstein.dev/frontend-fix: scope PTY attachment by selected conversation
- ai.bstein.dev/user-boundary: one OIDC subject with an isolated PVC subdirectory
- spec:
- serviceAccountName: hermes-chat
- automountServiceAccountToken: true
- securityContext:
- fsGroup: 10000
- fsGroupChangePolicy: OnRootMismatch
- seccompProfile:
- type: RuntimeDefault
- affinity:
- nodeAffinity:
- requiredDuringSchedulingIgnoredDuringExecution:
- nodeSelectorTerms:
- - matchExpressions:
- - key: kubernetes.io/arch
- operator: In
- values:
- - arm64
- - key: node-role.kubernetes.io/worker
- operator: In
- values:
- - "true"
- - key: kubernetes.io/hostname
- operator: NotIn
- values:
- - titan-13
- - titan-15
- - titan-17
- - titan-18
- - titan-19
- preferredDuringSchedulingIgnoredDuringExecution:
- - weight: 100
- preference:
- matchExpressions:
- - key: atlas.bstein.dev/spillover
- operator: DoesNotExist
- - weight: 90
- preference:
- matchExpressions:
- - key: hardware
- operator: In
- values:
- - rpi5
- - weight: 50
- preference:
- matchExpressions:
- - key: hardware
- operator: In
- values:
- - rpi4
- initContainers:
- - name: migrate-user-sessions
- image: registry.bstein.dev/bstein/hermes-agent@sha256:15c5c538c0b58686af2e54e10bc870b23284789d485a609349df24ed3053622f
- imagePullPolicy: IfNotPresent
- command:
- - /opt/hermes/bin/hermes-session-migrate
- env:
- - name: HERMES_HOME
- value: /storage/users/4794ab8284a14d421eaeea3e
- - name: HERMES_MIGRATE_SOURCE_HOME
- value: /storage
- - name: HERMES_MIGRATE_USER_ID
- value: 26b56113-0ec5-40b0-be7d-c2cd862f9bbc
- - name: HERMES_MIGRATE_SESSION_IDS
- value: 20260802_182723_9f53f1,20260802_173527_9dc51b
- securityContext:
- allowPrivilegeEscalation: false
- runAsUser: 10000
- runAsGroup: 10000
- seccompProfile:
- type: RuntimeDefault
- volumeMounts:
- - name: home
- mountPath: /storage
- resources:
- requests:
- cpu: 25m
- memory: 64Mi
- limits:
- cpu: 250m
- memory: 256Mi
- - name: init-config
- image: busybox:1.37
- imagePullPolicy: IfNotPresent
- command:
- - sh
- - -c
- - |
- set -eu
- user_home=/storage/users/4794ab8284a14d421eaeea3e
- mkdir -p "${user_home}/workspace/skills" "${user_home}/home/.local/bin" "${user_home}/logs"
- cp /config/config.yaml "${user_home}/config.yaml"
- cp /config/SOUL.md "${user_home}/SOUL.md"
- cp /config/AGENTS.md "${user_home}/workspace/AGENTS.md"
- touch "${user_home}/.env"
- if ! grep -q '^API_SERVER_KEY=' "${user_home}/.env"; then
- api_key="$(dd if=/dev/urandom bs=32 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n')"
- printf '\nAPI_SERVER_KEY=%s\n' "${api_key}" >> "${user_home}/.env"
- fi
- chmod 0600 "${user_home}/.env"
- chown -R 10000:10000 "${user_home}"
- securityContext:
- allowPrivilegeEscalation: false
- runAsUser: 0
- runAsGroup: 0
- seccompProfile:
- type: RuntimeDefault
- volumeMounts:
- - name: home
- mountPath: /storage
- - name: config
- mountPath: /config
- readOnly: true
- resources:
- requests:
- cpu: 25m
- memory: 32Mi
- limits:
- cpu: 100m
- memory: 64Mi
- - name: install-kubectl
- image: bitnami/kubectl@sha256:554ab88b1858e8424c55de37ad417b16f2a0e65d1607aa0f3fe3ce9b9f10b131
- imagePullPolicy: IfNotPresent
- command:
- - /bin/sh
- - -c
- - |
- set -eu
- cp "$(command -v kubectl)" /tools/kubectl
- chmod 0755 /tools/kubectl
- chown 10000:10000 /tools/kubectl
- securityContext:
- allowPrivilegeEscalation: false
- runAsUser: 0
- runAsGroup: 0
- seccompProfile:
- type: RuntimeDefault
- volumeMounts:
- - name: tools
- mountPath: /tools
- resources:
- requests:
- cpu: 25m
- memory: 32Mi
- limits:
- cpu: 100m
- memory: 64Mi
- containers:
- - name: hermes-chat
- image: registry.bstein.dev/bstein/hermes-agent@sha256:15c5c538c0b58686af2e54e10bc870b23284789d485a609349df24ed3053622f
- imagePullPolicy: IfNotPresent
- args:
- - gateway
- - run
- ports:
- - name: dashboard
- containerPort: 9119
- protocol: TCP
- env:
- - name: HERMES_HOME
- value: /opt/data
- - name: HOME
- value: /opt/data/home
- - name: PATH
- value: /opt/data/home/.local/bin:/opt/hermes/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
- - name: HERMES_DASHBOARD
- value: "1"
- - name: HERMES_DASHBOARD_HOST
- value: 0.0.0.0
- - name: HERMES_DASHBOARD_PORT
- value: "9119"
- - name: HERMES_DASHBOARD_PUBLIC_URL
- value: https://chat.bstein.dev
- - name: HERMES_DASHBOARD_OIDC_ISSUER
- value: https://sso.bstein.dev/realms/atlas
- - name: HERMES_DASHBOARD_OIDC_CLIENT_ID
- value: hermes-chat-dashboard
- - name: HERMES_DASHBOARD_OIDC_SCOPES
- value: openid profile email groups
- - name: HERMES_DASHBOARD_OIDC_ALLOWED_USER_IDS
- value: 26b56113-0ec5-40b0-be7d-c2cd862f9bbc
- - name: API_SERVER_ENABLED
- value: "true"
- - name: API_SERVER_HOST
- value: 0.0.0.0
- - name: API_SERVER_PORT
- value: "8642"
- - name: API_SERVER_CORS_ORIGINS
- value: https://chat.bstein.dev
- volumeMounts:
- - name: home
- mountPath: /opt/data
- subPath: users/4794ab8284a14d421eaeea3e
- - name: tools
- mountPath: /usr/local/bin/kubectl
- subPath: kubectl
- readinessProbe:
- httpGet:
- path: /api/status
- port: dashboard
- initialDelaySeconds: 30
- periodSeconds: 10
- timeoutSeconds: 5
- livenessProbe:
- httpGet:
- path: /api/status
- port: dashboard
- initialDelaySeconds: 90
- periodSeconds: 30
- timeoutSeconds: 10
- securityContext:
- allowPrivilegeEscalation: false
- seccompProfile:
- type: RuntimeDefault
- resources:
- requests:
- cpu: 100m
- memory: 768Mi
- ephemeral-storage: 256Mi
- limits:
- cpu: "1"
- memory: 2Gi
- ephemeral-storage: 1Gi
- volumes:
- - name: home
- persistentVolumeClaim:
- claimName: hermes-chat-home
- - name: config
- configMap:
- name: hermes-chat-config
- - name: tools
- emptyDir: {}
diff --git a/services/hermes-chat/ingress.yaml b/services/hermes-chat/ingress.yaml
deleted file mode 100644
index e176231f5..000000000
--- a/services/hermes-chat/ingress.yaml
+++ /dev/null
@@ -1,27 +0,0 @@
-# services/hermes-chat/ingress.yaml
-apiVersion: networking.k8s.io/v1
-kind: Ingress
-metadata:
- name: chat
- namespace: hermes-chat
- annotations:
- cert-manager.io/cluster-issuer: letsencrypt
- traefik.ingress.kubernetes.io/router.entrypoints: websecure
- traefik.ingress.kubernetes.io/router.tls: "true"
-spec:
- ingressClassName: traefik
- tls:
- - hosts:
- - chat.bstein.dev
- secretName: chat-tls
- rules:
- - host: chat.bstein.dev
- http:
- paths:
- - path: /
- pathType: Prefix
- backend:
- service:
- name: hermes-chat
- port:
- name: dashboard
diff --git a/services/hermes-chat/kustomization.yaml b/services/hermes-chat/kustomization.yaml
index 4d3529d77..3e509f1a2 100644
--- a/services/hermes-chat/kustomization.yaml
+++ b/services/hermes-chat/kustomization.yaml
@@ -4,12 +4,4 @@ kind: Kustomization
namespace: hermes-chat
resources:
- namespace.yaml
- - serviceaccount.yaml
- - rbac.yaml
- - configmap.yaml
- pvc.yaml
- - deployment.yaml
- - service.yaml
- - networkpolicy.yaml
- - certificate.yaml
- - ingress.yaml
diff --git a/services/hermes-chat/networkpolicy.yaml b/services/hermes-chat/networkpolicy.yaml
deleted file mode 100644
index 1c6451204..000000000
--- a/services/hermes-chat/networkpolicy.yaml
+++ /dev/null
@@ -1,87 +0,0 @@
-# services/hermes-chat/networkpolicy.yaml
-apiVersion: networking.k8s.io/v1
-kind: NetworkPolicy
-metadata:
- name: hermes-chat-isolation
- namespace: hermes-chat
-spec:
- podSelector:
- matchLabels:
- app: hermes-chat
- policyTypes:
- - Ingress
- - Egress
- ingress:
- - from:
- - namespaceSelector:
- matchLabels:
- kubernetes.io/metadata.name: traefik
- podSelector:
- matchLabels:
- app.kubernetes.io/name: traefik
- ports:
- - protocol: TCP
- port: 9119
- egress:
- - to:
- - namespaceSelector:
- matchLabels:
- kubernetes.io/metadata.name: kube-system
- podSelector:
- matchLabels:
- k8s-app: kube-dns
- ports:
- - protocol: UDP
- port: 53
- - protocol: TCP
- port: 53
- - to:
- - namespaceSelector:
- matchLabels:
- kubernetes.io/metadata.name: hermes
- podSelector:
- matchLabels:
- app: hermes-model-gate
- ports:
- - protocol: TCP
- port: 8080
- # Atlas OIDC uses the public sso.bstein.dev issuer, which resolves to the
- # in-cluster Traefik load balancer for pods on the Atlas network.
- - to:
- - namespaceSelector:
- matchLabels:
- kubernetes.io/metadata.name: traefik
- podSelector:
- matchLabels:
- app: traefik
- ports:
- - protocol: TCP
- port: 443
- - to:
- - ipBlock:
- cidr: 10.43.0.1/32
- ports:
- - protocol: TCP
- port: 443
- # K3s applies egress policy after Service DNAT, so admit only the fixed API
- # server endpoints as well as the kubernetes Service IP above.
- - to:
- - ipBlock:
- cidr: 192.168.22.11/32
- - ipBlock:
- cidr: 192.168.22.12/32
- - ipBlock:
- cidr: 192.168.22.13/32
- ports:
- - protocol: TCP
- port: 6443
- - to:
- - ipBlock:
- cidr: 0.0.0.0/0
- except:
- - 10.0.0.0/8
- - 100.64.0.0/10
- - 127.0.0.0/8
- - 169.254.0.0/16
- - 172.16.0.0/12
- - 192.168.0.0/16
diff --git a/services/hermes-chat/rbac.yaml b/services/hermes-chat/rbac.yaml
deleted file mode 100644
index 4144da689..000000000
--- a/services/hermes-chat/rbac.yaml
+++ /dev/null
@@ -1,62 +0,0 @@
-# services/hermes-chat/rbac.yaml
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRole
-metadata:
- name: hermes-chat-observer
-rules:
- - apiGroups: [""]
- resources:
- - endpoints
- - events
- - namespaces
- - nodes
- - persistentvolumeclaims
- - persistentvolumes
- - pods
- - pods/log
- - replicationcontrollers
- - services
- verbs: ["get", "list", "watch"]
- - apiGroups: ["apps"]
- resources:
- - daemonsets
- - deployments
- - replicasets
- - statefulsets
- verbs: ["get", "list", "watch"]
- - apiGroups: ["batch"]
- resources:
- - cronjobs
- - jobs
- verbs: ["get", "list", "watch"]
- - apiGroups: ["networking.k8s.io"]
- resources:
- - ingresses
- - networkpolicies
- verbs: ["get", "list", "watch"]
- - apiGroups: ["helm.toolkit.fluxcd.io"]
- resources:
- - helmreleases
- verbs: ["get", "list", "watch"]
- - apiGroups: ["kustomize.toolkit.fluxcd.io"]
- resources:
- - kustomizations
- verbs: ["get", "list", "watch"]
- - apiGroups: ["source.toolkit.fluxcd.io"]
- resources:
- - gitrepositories
- - helmrepositories
- verbs: ["get", "list", "watch"]
----
-apiVersion: rbac.authorization.k8s.io/v1
-kind: ClusterRoleBinding
-metadata:
- name: hermes-chat-observer
-subjects:
- - kind: ServiceAccount
- name: hermes-chat
- namespace: hermes-chat
-roleRef:
- apiGroup: rbac.authorization.k8s.io
- kind: ClusterRole
- name: hermes-chat-observer
diff --git a/services/hermes-chat/service.yaml b/services/hermes-chat/service.yaml
deleted file mode 100644
index 8b74822ea..000000000
--- a/services/hermes-chat/service.yaml
+++ /dev/null
@@ -1,17 +0,0 @@
-# services/hermes-chat/service.yaml
-apiVersion: v1
-kind: Service
-metadata:
- name: hermes-chat
- namespace: hermes-chat
- labels:
- app: hermes-chat
-spec:
- type: ClusterIP
- selector:
- app: hermes-chat
- ports:
- - name: dashboard
- port: 9119
- targetPort: dashboard
- protocol: TCP
diff --git a/services/hermes-chat/serviceaccount.yaml b/services/hermes-chat/serviceaccount.yaml
deleted file mode 100644
index 88eca224c..000000000
--- a/services/hermes-chat/serviceaccount.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-# services/hermes-chat/serviceaccount.yaml
-apiVersion: v1
-kind: ServiceAccount
-metadata:
- name: hermes-chat
- namespace: hermes-chat
-automountServiceAccountToken: true
diff --git a/services/hermes/NOTES.md b/services/hermes/NOTES.md
index 2c107c0f6..f86b1cbc8 100644
--- a/services/hermes/NOTES.md
+++ b/services/hermes/NOTES.md
@@ -1,10 +1,38 @@
# Hermes on Atlas: operator guide
This is the mental model and demonstration script for the operator instance at
-`agent.bstein.dev`. Read it once, then prove each section in the live UI. The
-consumer instance at `chat.bstein.dev` is intentionally separate and is not the
+`triage.hermes.bstein.dev`. Read it once, then prove each section in the live UI. The
+consumer instance at `chat.hermes.bstein.dev` is intentionally separate and is not the
place to perform infrastructure triage.
+## Consumer chat and Telegram
+
+`chat.hermes.bstein.dev` uses the pinned Hermes WebUI rather than the operator
+dashboard. Keycloak still authenticates every browser request, and the tenant
+router permanently assigns each Keycloak subject to one Hermes process and one
+PVC. The four slots are an isolation pool, not a provider round robin: every
+user starts with the same automatic provider/fallback policy and may change the
+model or reasoning effort for their own conversation. The WebUI and API reject
+reasoning levels above `xhigh`.
+
+Telegram is optional. The Keycloak bootstrap creates
+`kv/atlas/hermes/chat-telegram` with a generated relay key and an empty
+`bot_token`. After creating the shared bot with BotFather, set only that field:
+
+```sh
+vault kv patch kv/atlas/hermes/chat-telegram bot_token=''
+```
+
+Restart or reconcile `hermes-chat-router` after changing the token. A user then
+signs in to the WebUI, selects `Telegram`, creates a ten-minute code, and sends
+the displayed `/link` command to the bot. The router accepts only direct chats,
+stores hashed Keycloak and Telegram identities, and forwards the message to
+that user's tenant API with the shared relay key. `/unlink` works from Telegram
+or the WebUI. Browser chat remains available when `bot_token` is empty.
+
+The bot token and relay key must never be added to Git or a Kubernetes Secret.
+The router does not log prompt bodies, raw Telegram IDs, link codes, or tokens.
+
## The one-sentence explanation
Hermes is the persistent agent runtime and control surface; Codex or the local
diff --git a/services/hermes/agent-certificate.yaml b/services/hermes/agent-certificate.yaml
index f5b52ae1b..486bab8ba 100644
--- a/services/hermes/agent-certificate.yaml
+++ b/services/hermes/agent-certificate.yaml
@@ -2,12 +2,14 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
- name: agent-tls
+ name: hermes-sites-tls
namespace: hermes
spec:
- secretName: agent-tls
+ secretName: hermes-sites-tls
issuerRef:
kind: ClusterIssuer
name: letsencrypt
dnsNames:
- - agent.bstein.dev
+ - agent.hermes.bstein.dev
+ - chat.hermes.bstein.dev
+ - triage.hermes.bstein.dev
diff --git a/services/hermes/agent-configmap.yaml b/services/hermes/agent-configmap.yaml
new file mode 100644
index 000000000..adaf8cd28
--- /dev/null
+++ b/services/hermes/agent-configmap.yaml
@@ -0,0 +1,193 @@
+# services/hermes/agent-configmap.yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: hermes-agent-config
+ namespace: hermes
+ labels:
+ app: hermes-agent
+data:
+ config.yaml: |
+ model:
+ provider: openai-codex
+ default: gpt-5.6-terra
+ model: gpt-5.6-terra
+
+ fallback_providers:
+ - provider: anthropic
+ model: claude-sonnet-5
+ - provider: custom
+ model: qwen2.5:14b-instruct-q4_0
+ base_url: http://ollama.ai.svc.cluster.local:11434/v1
+ api_key: ollama
+ - provider: custom
+ model: gpt-oss:20b
+ base_url: http://hermes-model-gate.hermes.svc.cluster.local:11434/v1
+ api_key: ollama
+
+ agent:
+ api_max_retries: 1
+ reasoning_effort: medium
+
+ toolsets:
+ - kanban
+
+ platform_toolsets:
+ cli:
+ - clarify
+ - file
+ - session_search
+ - skills
+ - terminal
+ - todo
+ - web
+ api_server:
+ - clarify
+ - file
+ - session_search
+ - skills
+ - terminal
+ - todo
+ - web
+
+ gateway:
+ api_server:
+ max_concurrent_runs: 4
+
+ kanban:
+ # The board is authoritative state; Herdr, invoked by the coordinator,
+ # owns worker execution so a task cannot launch twice.
+ dispatch_in_gateway: false
+ dispatch_interval_seconds: 15
+ failure_limit: 2
+ orchestrator_profile: default
+ default_assignee: codex-medium
+ max_in_progress_per_profile: 1
+ auto_decompose: true
+ auto_decompose_per_tick: 2
+ dispatch_stale_timeout_seconds: 14400
+
+ model_catalog:
+ enabled: true
+ ttl_hours: 1
+
+ skills:
+ creation_nudge_interval: 15
+ external_dirs:
+ - /opt/data/workspace/skills
+
+ terminal:
+ backend: local
+ cwd: /opt/data/workspace
+ timeout: 300
+ home_mode: auto
+
+ approvals:
+ mode: smart
+ deny:
+ - "*kubectl apply*"
+ - "*kubectl delete*"
+ - "*kubectl patch*"
+ - "*kubectl scale*"
+ - "*kubectl exec*"
+ - "*kubectl port-forward*"
+ - "*flux reconcile*"
+ - "*flux suspend*"
+ - "*flux resume*"
+ - "*vault kv*"
+
+ dashboard:
+ public_url: https://agent.hermes.bstein.dev
+
+ display:
+ compact: true
+ tool_progress: all
+ interim_assistant_messages: true
+ long_running_notifications: true
+
+ tool_loop_guardrails:
+ warnings_enabled: true
+ hard_stop_enabled: true
+ warn_after:
+ exact_failure: 2
+ same_tool_failure: 3
+ idempotent_no_progress: 2
+ hard_stop_after:
+ exact_failure: 5
+ same_tool_failure: 8
+ idempotent_no_progress: 5
+
+ updates:
+ pre_update_backup: quick
+ backup_keep: 5
+ non_interactive_local_changes: stash
+ SOUL.md: |
+ You are Brad's private Hermes coordinator at agent.hermes.bstein.dev. Turn
+ objectives into organized, reviewable delivery without making Brad manage
+ model names, terminals, or provider capacity.
+
+ Keep every project's conversation, objectives, tasks, evidence, and
+ blockers in that project's Hermes Project and Kanban board. Cassandra is
+ the initial project. Use Herdr as the execution fabric for persistent Codex
+ and Claude Code workers; you remain responsible for planning, routing,
+ fallback, review, and the final synthesized answer.
+
+ Prefer Codex for implementation, debugging, test loops, and focused repo
+ changes. Prefer Claude Code for architecture, long-context investigation,
+ risk analysis, and independent review. Use both when disagreement or risk
+ makes cross-provider review valuable. Never exceed xhigh effort.
+
+ Local Jetson inference is the first provider-independent fallback. Use it
+ for bounded classification, summaries, and continuity when hosted capacity
+ is constrained. Do not silently treat a local fallback as equivalent to a
+ high-risk xhigh review; disclose the downgrade and preserve the task.
+ AGENTS.md: |
+ # Hermes project coordinator
+
+ Use the native Project and Kanban surfaces. Cassandra uses project and board
+ slug `cassandra` with workspace `/opt/data/workspace/projects/cassandra`.
+ Put objectives needing decomposition in Triage. Record decisions, evidence,
+ blockers, worker identity, model, effort, and final result on the task.
+
+ ## Difficulty routing
+
+ - `low`: simple questions, lookup, formatting, or a tiny reversible edit.
+ - `medium`: normal bounded implementation or analysis with clear tests.
+ - `high`: multi-component work, difficult debugging, or material ambiguity.
+ - `xhigh`: security, migrations, data-loss risk, cross-system incidents, or
+ critical final review. `xhigh` is the hard maximum; never request max or
+ ultracode.
+
+ Read `/opt/data/workspace/coordinator/model-routing.json` before naming a
+ model. The hourly steward discovers the models currently available to both
+ accounts and preserves the last working route during catalog outages.
+ Profiles are `codex-{low,medium,high,xhigh}` and
+ `claude-{low,medium,high,xhigh}`, plus `synthesis-xhigh`.
+
+ For persistent coding work, plan or launch a worker with:
+
+ `herdr-dispatch --shape --effort [--provider codex|claude]`
+
+ Add `--start --project --task --prompt ` to
+ create a Herdr workspace and launch the selected CLI. Use `herdr agent list`,
+ `herdr agent wait`, `herdr agent read`, and `herdr agent prompt` to supervise
+ it. If Codex reports its first-use login requirement, run
+ `codex login --device-auth` once and ask Brad to complete the displayed code.
+
+ A hosted capacity failure should fall across providers at the same effort
+ before dropping to local inference. Do not duplicate a task that is still
+ running. When both providers contributed, use `synthesis-xhigh` only if the
+ objective's difficulty warrants it; otherwise synthesize at the original
+ effort.
+
+ This pod has no Kubernetes RBAC. Do not use it for automated test intake or
+ cluster mutation. Triage belongs at triage.hermes.bstein.dev and changes to
+ Atlas are delivered through the titan-iac Git/Flux workflow.
+ START-HERE.md: |
+ # Agent Hermes
+
+ Select the Cassandra project and state the outcome you want. Hermes will
+ classify its difficulty, choose Codex or Claude Code, preserve the task on
+ the Cassandra board, supervise the worker through Herdr, and synthesize the
+ evidence. The first native Codex worker requires one device-code login;
+ subsequent sessions persist on the agent volume.
diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml
new file mode 100644
index 000000000..a5990288e
--- /dev/null
+++ b/services/hermes/agent-deployment.yaml
@@ -0,0 +1,359 @@
+# services/hermes/agent-deployment.yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: hermes-agent
+ namespace: hermes
+ labels:
+ app: hermes-agent
+spec:
+ replicas: 1
+ revisionHistoryLimit: 2
+ progressDeadlineSeconds: 2700
+ strategy:
+ type: Recreate
+ selector:
+ matchLabels:
+ app: hermes-agent
+ template:
+ metadata:
+ labels:
+ app: hermes-agent
+ annotations:
+ ai.bstein.dev/role: project-coordinator
+ ai.bstein.dev/execution: Herdr-supervised Codex and Claude Code
+ ai.bstein.dev/model-policy: difficulty-aware low through xhigh, cross-provider fallback
+ ai.bstein.dev/placement: titan-20 preferred, Jetson preferred, arm64 fallback
+ ai.bstein.dev/config-rev: "20260808-herdr-coordinator"
+ vault.hashicorp.com/agent-inject: "true"
+ vault.hashicorp.com/role: hermes-agent
+ vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens
+ vault.hashicorp.com/agent-inject-template-anthropic-token: |
+ {{- with secret "kv/data/atlas/hermes/agent-tokens" -}}
+ {{ .Data.data.anthropic_oauth_token }}
+ {{- end }}
+ vault.hashicorp.com/agent-inject-secret-gitea-token: kv/data/atlas/hermes/agent-tokens
+ vault.hashicorp.com/agent-inject-template-gitea-token: |
+ {{- with secret "kv/data/atlas/hermes/agent-tokens" -}}
+ {{- with index .Data.data "gitea_token" -}}
+ {{ . }}
+ {{- end -}}
+ {{- end }}
+ vault.hashicorp.com/agent-pre-populate-only: "true"
+ vault.hashicorp.com/agent-init-first: "true"
+ vault.hashicorp.com/agent-requests-cpu: 25m
+ vault.hashicorp.com/agent-requests-mem: 32Mi
+ vault.hashicorp.com/agent-limits-cpu: 100m
+ vault.hashicorp.com/agent-limits-mem: 128Mi
+ spec:
+ serviceAccountName: hermes-agent
+ automountServiceAccountToken: true
+ securityContext:
+ fsGroup: 10000
+ fsGroupChangePolicy: OnRootMismatch
+ seccompProfile:
+ type: RuntimeDefault
+ affinity:
+ nodeAffinity:
+ requiredDuringSchedulingIgnoredDuringExecution:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/arch
+ operator: In
+ values: [arm64]
+ - key: node-role.kubernetes.io/worker
+ operator: In
+ values: ["true"]
+ - key: kubernetes.io/hostname
+ operator: NotIn
+ values: [titan-13, titan-15, titan-17, titan-18, titan-19]
+ preferredDuringSchedulingIgnoredDuringExecution:
+ - weight: 100
+ preference:
+ matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values: [titan-20]
+ - weight: 90
+ preference:
+ matchExpressions:
+ - key: jetson
+ operator: In
+ values: ["true"]
+ - weight: 50
+ preference:
+ matchExpressions:
+ - key: hardware
+ operator: In
+ values: [rpi5]
+ initContainers:
+ - name: init-config
+ image: busybox:1.37
+ imagePullPolicy: IfNotPresent
+ command:
+ - sh
+ - -c
+ - |
+ set -eu
+ env_file=/opt/data/.env
+ mkdir -p \
+ /opt/data/home/.claude \
+ /opt/data/home/.codex \
+ /opt/data/home/.config/herdr \
+ /opt/data/herdr \
+ /opt/data/logs \
+ /opt/data/tools/bin \
+ /opt/data/workspace/coordinator \
+ /opt/data/workspace/projects \
+ /opt/data/workspace/skills
+ cp /config/config.yaml /opt/data/config.yaml
+ cp /config/SOUL.md /opt/data/SOUL.md
+ cp /config/AGENTS.md /opt/data/workspace/AGENTS.md
+ cp /config/START-HERE.md /opt/data/workspace/START-HERE.md
+ touch "${env_file}"
+ upsert_env() {
+ key="$1"
+ value="$2"
+ { grep -v "^${key}=" "${env_file}" || true; printf '%s=%s\n' "${key}" "${value}"; } > "${env_file}.tmp"
+ mv "${env_file}.tmp" "${env_file}"
+ }
+ if ! grep -q '^API_SERVER_KEY=' "${env_file}"; then
+ api_key="$(dd if=/dev/urandom bs=32 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n')"
+ upsert_env API_SERVER_KEY "${api_key}"
+ fi
+ if [ -s /vault/secrets/anthropic-token ]; then
+ token="$(tr -d '\r\n' < /vault/secrets/anthropic-token)"
+ [ -z "${token}" ] || upsert_env CLAUDE_CODE_OAUTH_TOKEN "${token}"
+ fi
+ if [ -s /vault/secrets/gitea-token ]; then
+ token="$(tr -d '\r\n' < /vault/secrets/gitea-token)"
+ case "${token}" in ""|""|"") ;; *) upsert_env GITEA_TOKEN "${token}" ;; esac
+ fi
+ upsert_env GITEA_USERNAME bstein
+ upsert_env GIT_ASKPASS /opt/coordinator/gitea_askpass.sh
+ upsert_env GIT_TERMINAL_PROMPT 0
+ chmod 0600 "${env_file}"
+ chown -R 10000:10000 /opt/data
+ securityContext:
+ allowPrivilegeEscalation: false
+ runAsUser: 0
+ runAsGroup: 0
+ seccompProfile:
+ type: RuntimeDefault
+ volumeMounts:
+ - name: home
+ mountPath: /opt/data
+ - name: config
+ mountPath: /config
+ readOnly: true
+ resources:
+ requests: {cpu: 25m, memory: 32Mi}
+ limits: {cpu: 100m, memory: 64Mi}
+ - name: install-agent-tools
+ image: registry.bstein.dev/bstein/hermes-agent@sha256:15c5c538c0b58686af2e54e10bc870b23284789d485a609349df24ed3053622f
+ imagePullPolicy: IfNotPresent
+ command:
+ - sh
+ - -c
+ - |
+ set -eu
+ tools=/opt/data/tools
+ mkdir -p "${tools}/bin"
+ herdr_version="$("${tools}/bin/herdr" --version 2>/dev/null || true)"
+ case "${herdr_version}" in *0.8.0*) herdr_ready=1 ;; *) herdr_ready=0 ;; esac
+ if [ "${herdr_ready}" != "1" ]; then
+ curl -fsSL -o "${tools}/bin/herdr.tmp" https://github.com/herdrdev/herdr/releases/download/v0.8.0/herdr-linux-aarch64
+ printf '%s %s\n' f647ac66468d9efbc642fe534fb284468f0aea60641606fc008dfc0d82a3ca87 "${tools}/bin/herdr.tmp" | sha256sum -c -
+ chmod 0755 "${tools}/bin/herdr.tmp"
+ mv "${tools}/bin/herdr.tmp" "${tools}/bin/herdr"
+ fi
+ if [ ! -f "${tools}/.cli-versions-0.147.0-2.1.226" ]; then
+ npm install --global --omit=dev --no-audit --no-fund --prefix "${tools}" \
+ @openai/codex@0.147.0 \
+ @anthropic-ai/claude-code@2.1.226
+ touch "${tools}/.cli-versions-0.147.0-2.1.226"
+ fi
+ securityContext:
+ allowPrivilegeEscalation: false
+ runAsUser: 10000
+ runAsGroup: 10000
+ seccompProfile:
+ type: RuntimeDefault
+ volumeMounts:
+ - name: home
+ mountPath: /opt/data
+ resources:
+ requests: {cpu: 100m, memory: 256Mi}
+ limits: {cpu: "1", memory: 1Gi}
+ - name: patch-auth
+ image: registry.bstein.dev/bstein/hermes-agent@sha256:15c5c538c0b58686af2e54e10bc870b23284789d485a609349df24ed3053622f
+ imagePullPolicy: IfNotPresent
+ command:
+ - /opt/hermes/.venv/bin/python
+ - /opt/coordinator/patch_hermes_auth.py
+ - /opt/hermes/hermes_cli/auth.py
+ - /patched/auth.py
+ securityContext:
+ allowPrivilegeEscalation: false
+ runAsUser: 10000
+ runAsGroup: 10000
+ seccompProfile:
+ type: RuntimeDefault
+ volumeMounts:
+ - name: coordinator
+ mountPath: /opt/coordinator
+ readOnly: true
+ - name: auth-patch
+ mountPath: /patched
+ resources:
+ requests: {cpu: 25m, memory: 64Mi}
+ limits: {cpu: 100m, memory: 128Mi}
+ - name: bootstrap-coordinator
+ image: registry.bstein.dev/bstein/hermes-agent@sha256:15c5c538c0b58686af2e54e10bc870b23284789d485a609349df24ed3053622f
+ imagePullPolicy: IfNotPresent
+ command:
+ - /opt/hermes/.venv/bin/python
+ - /opt/coordinator/hermes_coordinator.py
+ - --once
+ env:
+ - {name: HERMES_HOME, value: /opt/data}
+ - {name: HERMES_AUTH_FILE, value: /shared-auth/auth.json}
+ - {name: HOME, value: /opt/data/home}
+ - {name: PYTHONPATH, value: /opt/hermes}
+ securityContext:
+ allowPrivilegeEscalation: false
+ runAsUser: 10000
+ runAsGroup: 10000
+ seccompProfile:
+ type: RuntimeDefault
+ volumeMounts:
+ - {name: home, mountPath: /opt/data}
+ - {name: provider-auth, mountPath: /shared-auth}
+ - {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
+ - {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
+ resources:
+ requests: {cpu: 50m, memory: 128Mi}
+ limits: {cpu: 500m, memory: 512Mi}
+ containers:
+ - name: hermes
+ image: registry.bstein.dev/bstein/hermes-agent@sha256:15c5c538c0b58686af2e54e10bc870b23284789d485a609349df24ed3053622f
+ imagePullPolicy: IfNotPresent
+ args: [gateway, run]
+ ports:
+ - {name: dashboard, containerPort: 9119, protocol: TCP}
+ env:
+ - {name: HERMES_HOME, value: /opt/data}
+ - {name: HERMES_AUTH_FILE, value: /shared-auth/auth.json}
+ - {name: HOME, value: /opt/data/home}
+ - {name: CODEX_HOME, value: /opt/data/home/.codex}
+ - {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude}
+ - {name: HERDR_CONFIG_PATH, value: /opt/data/home/.config/herdr/config.toml}
+ - {name: HERDR_SOCKET_PATH, value: /opt/data/herdr/herdr.sock}
+ - {name: PATH, value: /opt/data/tools/bin:/opt/data/home/.local/bin:/opt/hermes/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin}
+ - {name: HERMES_DASHBOARD, value: "1"}
+ - {name: HERMES_DASHBOARD_HOST, value: 0.0.0.0}
+ - {name: HERMES_DASHBOARD_PORT, value: "9119"}
+ - {name: HERMES_DASHBOARD_PUBLIC_URL, value: https://agent.hermes.bstein.dev}
+ volumeMounts:
+ - {name: home, mountPath: /opt/data}
+ - {name: provider-auth, mountPath: /shared-auth}
+ - {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
+ - {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
+ - {name: coordinator, mountPath: /opt/data/home/.local/bin/herdr-dispatch, subPath: herdr_dispatch.py, readOnly: true}
+ readinessProbe:
+ httpGet: {path: /api/status, port: dashboard}
+ initialDelaySeconds: 30
+ periodSeconds: 10
+ timeoutSeconds: 5
+ livenessProbe:
+ httpGet: {path: /api/status, port: dashboard}
+ initialDelaySeconds: 90
+ periodSeconds: 30
+ timeoutSeconds: 10
+ securityContext:
+ allowPrivilegeEscalation: false
+ runAsUser: 10000
+ runAsGroup: 10000
+ seccompProfile:
+ type: RuntimeDefault
+ resources:
+ requests: {cpu: 500m, memory: 1Gi}
+ limits: {cpu: "2", memory: 4Gi}
+ - name: herdr-server
+ image: registry.bstein.dev/bstein/hermes-agent@sha256:15c5c538c0b58686af2e54e10bc870b23284789d485a609349df24ed3053622f
+ imagePullPolicy: IfNotPresent
+ command:
+ - sh
+ - -c
+ - |
+ set -eu
+ set -a
+ . /opt/data/.env
+ set +a
+ herdr server &
+ server_pid=$!
+ trap 'kill "${server_pid}" 2>/dev/null || true' TERM INT
+ for attempt in $(seq 1 60); do
+ if herdr status server >/dev/null 2>&1; then break; fi
+ sleep 1
+ done
+ herdr integration install codex || true
+ herdr integration install claude || true
+ wait "${server_pid}"
+ env:
+ - {name: HOME, value: /opt/data/home}
+ - {name: CODEX_HOME, value: /opt/data/home/.codex}
+ - {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude}
+ - {name: HERDR_CONFIG_PATH, value: /opt/data/home/.config/herdr/config.toml}
+ - {name: HERDR_SOCKET_PATH, value: /opt/data/herdr/herdr.sock}
+ - {name: PATH, value: /opt/data/tools/bin:/usr/local/bin:/usr/bin:/bin}
+ securityContext:
+ allowPrivilegeEscalation: false
+ runAsUser: 10000
+ runAsGroup: 10000
+ seccompProfile:
+ type: RuntimeDefault
+ volumeMounts:
+ - {name: home, mountPath: /opt/data}
+ resources:
+ requests: {cpu: 100m, memory: 256Mi}
+ limits: {cpu: "1", memory: 2Gi}
+ - name: model-steward
+ image: registry.bstein.dev/bstein/hermes-agent@sha256:15c5c538c0b58686af2e54e10bc870b23284789d485a609349df24ed3053622f
+ imagePullPolicy: IfNotPresent
+ command: [/opt/hermes/.venv/bin/python, /opt/coordinator/hermes_coordinator.py, --loop, --interval, "3600"]
+ env:
+ - {name: HERMES_HOME, value: /opt/data}
+ - {name: HERMES_AUTH_FILE, value: /shared-auth/auth.json}
+ - {name: HOME, value: /opt/data/home}
+ - {name: PYTHONPATH, value: /opt/hermes}
+ securityContext:
+ allowPrivilegeEscalation: false
+ runAsUser: 10000
+ runAsGroup: 10000
+ seccompProfile:
+ type: RuntimeDefault
+ volumeMounts:
+ - {name: home, mountPath: /opt/data}
+ - {name: provider-auth, mountPath: /shared-auth}
+ - {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
+ - {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
+ resources:
+ requests: {cpu: 25m, memory: 128Mi}
+ limits: {cpu: 250m, memory: 512Mi}
+ volumes:
+ - name: home
+ persistentVolumeClaim:
+ claimName: hermes-agent-home
+ - name: provider-auth
+ persistentVolumeClaim:
+ claimName: hermes-provider-auth
+ - name: config
+ configMap:
+ name: hermes-agent-config
+ - name: coordinator
+ configMap:
+ name: hermes-coordinator
+ defaultMode: 0555
+ - name: auth-patch
+ emptyDir: {}
diff --git a/services/hermes/agent-ingress.yaml b/services/hermes/agent-ingress.yaml
index 9581a95b4..1288f81ad 100644
--- a/services/hermes/agent-ingress.yaml
+++ b/services/hermes/agent-ingress.yaml
@@ -2,7 +2,7 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
- name: agent
+ name: hermes-sites
namespace: hermes
annotations:
cert-manager.io/cluster-issuer: letsencrypt
@@ -11,16 +11,39 @@ metadata:
spec:
ingressClassName: traefik
tls:
- - hosts: ["agent.bstein.dev"]
- secretName: agent-tls
+ - hosts:
+ - agent.hermes.bstein.dev
+ - chat.hermes.bstein.dev
+ - triage.hermes.bstein.dev
+ secretName: hermes-sites-tls
rules:
- - host: agent.bstein.dev
+ - host: agent.hermes.bstein.dev
http:
paths:
- path: /
pathType: Prefix
backend:
service:
- name: oauth2-proxy-hermes
+ name: oauth2-proxy-hermes-agent
+ port:
+ name: http
+ - host: chat.hermes.bstein.dev
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: oauth2-proxy-hermes-chat
+ port:
+ name: http
+ - host: triage.hermes.bstein.dev
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: oauth2-proxy-hermes-triage
port:
name: http
diff --git a/services/hermes/chat-configmap.yaml b/services/hermes/chat-configmap.yaml
new file mode 100644
index 000000000..9ee5cf044
--- /dev/null
+++ b/services/hermes/chat-configmap.yaml
@@ -0,0 +1,57 @@
+# services/hermes/chat-configmap.yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: hermes-chat-config
+ namespace: hermes
+ labels:
+ app: hermes-chat-tenant
+data:
+ config.yaml: |
+ model:
+ provider: openai-codex
+ default: gpt-5.6-terra
+ model: gpt-5.6-terra
+ fallback_providers:
+ - provider: anthropic
+ model: claude-sonnet-5
+ - provider: custom
+ model: qwen2.5:14b-instruct-q4_0
+ base_url: http://ollama.ai.svc.cluster.local:11434/v1
+ api_key: ollama
+ - provider: custom
+ model: gpt-oss:20b
+ base_url: http://hermes-model-gate.hermes.svc.cluster.local:11434/v1
+ api_key: ollama
+ agent:
+ api_max_retries: 1
+ model_catalog:
+ enabled: true
+ ttl_hours: 1
+ platform_toolsets:
+ cli: [clarify, session_search, web]
+ api_server: [clarify, session_search, web]
+ dashboard:
+ public_url: https://chat.hermes.bstein.dev
+ display:
+ compact: true
+ tool_progress: all
+ interim_assistant_messages: true
+ long_running_notifications: true
+ SOUL.md: |
+ You are a high-quality private AI chat assistant. Help the current person
+ with questions, writing, research, planning, and learning. Be direct,
+ thoughtful, and careful. Use public web research when freshness matters.
+
+ This is a personal sandbox. Never attempt cluster administration, private
+ service access, repository modification, terminal execution, credentials,
+ or coordination of Brad's project agents. Those capabilities are not part
+ of this chat product. The user's conversations and files must never be
+ mixed with another Keycloak user's state.
+ AGENTS.md: |
+ # Private Hermes chat
+
+ This runtime belongs to one authenticated Keycloak identity and one private
+ persistent volume. Provide conversational help with clarify, session search,
+ and public web tools only. Do not claim access to Kubernetes, Vault, Gitea,
+ Brad's projects, other users, the agent coordinator, or automated triage.
diff --git a/services/hermes/chat-router.yaml b/services/hermes/chat-router.yaml
new file mode 100644
index 000000000..d4ab57e69
--- /dev/null
+++ b/services/hermes/chat-router.yaml
@@ -0,0 +1,135 @@
+# services/hermes/chat-router.yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: hermes-chat-router
+ namespace: hermes
+ labels:
+ app: hermes-chat-router
+spec:
+ replicas: 1
+ revisionHistoryLimit: 2
+ strategy:
+ type: Recreate
+ selector:
+ matchLabels:
+ app: hermes-chat-router
+ template:
+ metadata:
+ labels:
+ app: hermes-chat-router
+ annotations:
+ ai.bstein.dev/role: privacy-preserving-chat-tenant-router
+ ai.bstein.dev/config-rev: "20260808-webui-telegram"
+ vault.hashicorp.com/agent-inject: "true"
+ vault.hashicorp.com/agent-pre-populate-only: "true"
+ vault.hashicorp.com/agent-init-first: "true"
+ vault.hashicorp.com/role: hermes-chat
+ vault.hashicorp.com/agent-inject-secret-telegram-config: kv/data/atlas/hermes/chat-telegram
+ vault.hashicorp.com/agent-inject-template-telegram-config: |
+ {{- with secret "kv/data/atlas/hermes/chat-telegram" -}}
+ bot_token={{ .Data.data.bot_token }}
+ relay_key={{ .Data.data.relay_key }}
+ {{- end }}
+ spec:
+ serviceAccountName: hermes-chat
+ automountServiceAccountToken: true
+ securityContext:
+ fsGroup: 10000
+ fsGroupChangePolicy: OnRootMismatch
+ seccompProfile:
+ type: RuntimeDefault
+ affinity:
+ nodeAffinity:
+ requiredDuringSchedulingIgnoredDuringExecution:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/arch
+ operator: In
+ values: [arm64]
+ - key: node-role.kubernetes.io/worker
+ operator: In
+ values: ["true"]
+ - key: kubernetes.io/hostname
+ operator: NotIn
+ values: [titan-13, titan-15, titan-17, titan-18, titan-19]
+ preferredDuringSchedulingIgnoredDuringExecution:
+ - weight: 100
+ preference:
+ matchExpressions:
+ - key: hardware
+ operator: In
+ values: [rpi5]
+ initContainers:
+ - name: build-router
+ image: golang:1.24-alpine@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191
+ imagePullPolicy: IfNotPresent
+ command:
+ - sh
+ - -c
+ - |
+ set -eu
+ cd /src
+ GOCACHE=/tmp/go-cache GO111MODULE=off CGO_ENABLED=0 go test .
+ GOCACHE=/tmp/go-cache GO111MODULE=off CGO_ENABLED=0 go build -trimpath -o /tools/chat-router .
+ chmod 0755 /tools/chat-router
+ securityContext:
+ allowPrivilegeEscalation: false
+ runAsUser: 10000
+ runAsGroup: 10000
+ seccompProfile:
+ type: RuntimeDefault
+ volumeMounts:
+ - {name: source, mountPath: /src, readOnly: true}
+ - {name: tools, mountPath: /tools}
+ - {name: tmp, mountPath: /tmp}
+ resources:
+ requests: {cpu: 100m, memory: 128Mi}
+ limits: {cpu: "1", memory: 512Mi}
+ containers:
+ - name: router
+ image: busybox:1.37
+ imagePullPolicy: IfNotPresent
+ command: [/tools/chat-router]
+ ports:
+ - {name: http, containerPort: 8080, protocol: TCP}
+ env:
+ - {name: TENANT_SLOTS, value: "4"}
+ - {name: TENANT_STATE_PATH, value: /state/tenants.json}
+ - {name: TELEGRAM_CONFIG_PATH, value: /vault/secrets/telegram-config}
+ readinessProbe:
+ httpGet: {path: /healthz, port: http}
+ initialDelaySeconds: 2
+ periodSeconds: 10
+ livenessProbe:
+ httpGet: {path: /healthz, port: http}
+ initialDelaySeconds: 10
+ periodSeconds: 20
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop: [ALL]
+ readOnlyRootFilesystem: true
+ runAsUser: 10000
+ runAsGroup: 10000
+ seccompProfile:
+ type: RuntimeDefault
+ volumeMounts:
+ - {name: tools, mountPath: /tools, readOnly: true}
+ - {name: state, mountPath: /state}
+ - {name: tmp, mountPath: /tmp}
+ resources:
+ requests: {cpu: 25m, memory: 32Mi}
+ limits: {cpu: 250m, memory: 128Mi}
+ volumes:
+ - name: source
+ configMap:
+ name: hermes-chat-router-source
+ - name: tools
+ emptyDir: {}
+ - name: state
+ persistentVolumeClaim:
+ claimName: hermes-chat-router-state
+ - name: tmp
+ emptyDir:
+ sizeLimit: 128Mi
diff --git a/services/hermes/chat-statefulset.yaml b/services/hermes/chat-statefulset.yaml
new file mode 100644
index 000000000..339a86bfa
--- /dev/null
+++ b/services/hermes/chat-statefulset.yaml
@@ -0,0 +1,275 @@
+# services/hermes/chat-statefulset.yaml
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+ name: hermes-chat-tenant
+ namespace: hermes
+ labels:
+ app: hermes-chat-tenant
+spec:
+ serviceName: hermes-chat-tenant
+ replicas: 4
+ podManagementPolicy: Parallel
+ updateStrategy:
+ type: RollingUpdate
+ selector:
+ matchLabels:
+ app: hermes-chat-tenant
+ template:
+ metadata:
+ labels:
+ app: hermes-chat-tenant
+ annotations:
+ ai.bstein.dev/role: isolated-user-chat
+ ai.bstein.dev/isolation: one Hermes process and PVC per Keycloak subject
+ ai.bstein.dev/model-policy: uniform automatic policy with per-user overrides
+ ai.bstein.dev/config-rev: "20260808-webui-telegram"
+ vault.hashicorp.com/agent-inject: "true"
+ vault.hashicorp.com/role: hermes-chat
+ vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens
+ vault.hashicorp.com/agent-inject-template-anthropic-token: |
+ {{- with secret "kv/data/atlas/hermes/agent-tokens" -}}
+ {{ .Data.data.anthropic_oauth_token }}
+ {{- end }}
+ vault.hashicorp.com/agent-inject-secret-chat-relay-key: kv/data/atlas/hermes/chat-telegram
+ vault.hashicorp.com/agent-inject-template-chat-relay-key: |
+ {{- with secret "kv/data/atlas/hermes/chat-telegram" -}}
+ {{ .Data.data.relay_key }}
+ {{- end }}
+ vault.hashicorp.com/agent-pre-populate-only: "true"
+ vault.hashicorp.com/agent-init-first: "true"
+ vault.hashicorp.com/agent-requests-cpu: 25m
+ vault.hashicorp.com/agent-requests-mem: 32Mi
+ vault.hashicorp.com/agent-limits-cpu: 100m
+ vault.hashicorp.com/agent-limits-mem: 128Mi
+ spec:
+ serviceAccountName: hermes-chat
+ automountServiceAccountToken: true
+ securityContext:
+ fsGroup: 10000
+ fsGroupChangePolicy: OnRootMismatch
+ seccompProfile:
+ type: RuntimeDefault
+ affinity:
+ nodeAffinity:
+ requiredDuringSchedulingIgnoredDuringExecution:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: kubernetes.io/arch
+ operator: In
+ values: [arm64]
+ - key: node-role.kubernetes.io/worker
+ operator: In
+ values: ["true"]
+ - key: kubernetes.io/hostname
+ operator: NotIn
+ values: [titan-13, titan-15, titan-17, titan-18, titan-19]
+ preferredDuringSchedulingIgnoredDuringExecution:
+ - weight: 100
+ preference:
+ matchExpressions:
+ - key: hardware
+ operator: In
+ values: [rpi5]
+ - weight: 40
+ preference:
+ matchExpressions:
+ - key: hardware
+ operator: In
+ values: [rpi4]
+ podAntiAffinity:
+ preferredDuringSchedulingIgnoredDuringExecution:
+ - weight: 100
+ podAffinityTerm:
+ labelSelector:
+ matchLabels:
+ app: hermes-chat-tenant
+ topologyKey: kubernetes.io/hostname
+ initContainers:
+ - name: init-config
+ image: busybox:1.37
+ imagePullPolicy: IfNotPresent
+ command:
+ - sh
+ - -c
+ - |
+ set -eu
+ mkdir -p /opt/data/home/.local/bin /opt/data/logs /opt/data/workspace
+ cp /config/config.yaml /opt/data/config.yaml
+ cp /config/SOUL.md /opt/data/SOUL.md
+ cp /config/AGENTS.md /opt/data/workspace/AGENTS.md
+ touch /opt/data/.env
+ relay_key=""
+ if [ -s /vault/secrets/chat-relay-key ]; then
+ relay_key="$(tr -d '\r\n' < /vault/secrets/chat-relay-key)"
+ fi
+ if [ -z "${relay_key}" ]; then
+ api_key="$(dd if=/dev/urandom bs=32 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n')"
+ relay_key="${api_key}"
+ fi
+ { grep -v '^API_SERVER_KEY=' /opt/data/.env || true; printf 'API_SERVER_KEY=%s\n' "${relay_key}"; } > /opt/data/.env.tmp
+ mv /opt/data/.env.tmp /opt/data/.env
+ if [ -s /vault/secrets/anthropic-token ]; then
+ token="$(tr -d '\r\n' < /vault/secrets/anthropic-token)"
+ if [ -n "${token}" ]; then
+ { grep -v '^CLAUDE_CODE_OAUTH_TOKEN=' /opt/data/.env || true; printf 'CLAUDE_CODE_OAUTH_TOKEN=%s\n' "${token}"; } > /opt/data/.env.tmp
+ mv /opt/data/.env.tmp /opt/data/.env
+ fi
+ fi
+ chmod 0600 /opt/data/.env
+ chown -R 10000:10000 /opt/data
+ securityContext:
+ allowPrivilegeEscalation: false
+ runAsUser: 0
+ runAsGroup: 0
+ seccompProfile:
+ type: RuntimeDefault
+ volumeMounts:
+ - {name: home, mountPath: /opt/data}
+ - {name: config, mountPath: /config, readOnly: true}
+ resources:
+ requests: {cpu: 25m, memory: 32Mi}
+ limits: {cpu: 100m, memory: 64Mi}
+ - name: patch-auth
+ image: registry.bstein.dev/bstein/hermes-agent@sha256:15c5c538c0b58686af2e54e10bc870b23284789d485a609349df24ed3053622f
+ imagePullPolicy: IfNotPresent
+ command:
+ - /opt/hermes/.venv/bin/python
+ - /opt/coordinator/patch_hermes_auth.py
+ - /opt/hermes/hermes_cli/auth.py
+ - /patched/auth.py
+ securityContext:
+ allowPrivilegeEscalation: false
+ runAsUser: 10000
+ runAsGroup: 10000
+ seccompProfile:
+ type: RuntimeDefault
+ volumeMounts:
+ - {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
+ - {name: auth-patch, mountPath: /patched}
+ resources:
+ requests: {cpu: 25m, memory: 64Mi}
+ limits: {cpu: 100m, memory: 128Mi}
+ containers:
+ - name: hermes
+ image: registry.bstein.dev/bstein/hermes-agent@sha256:15c5c538c0b58686af2e54e10bc870b23284789d485a609349df24ed3053622f
+ imagePullPolicy: IfNotPresent
+ args: [gateway, run]
+ ports:
+ - {name: api, containerPort: 8642, protocol: TCP}
+ env:
+ - {name: HERMES_HOME, value: /opt/data}
+ - {name: HERMES_AUTH_FILE, value: /shared-auth/auth.json}
+ - {name: HOME, value: /opt/data/home}
+ - {name: PATH, value: /opt/data/home/.local/bin:/opt/hermes/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin}
+ - {name: HERMES_DASHBOARD, value: "0"}
+ - {name: API_SERVER_ENABLED, value: "true"}
+ - {name: API_SERVER_HOST, value: 0.0.0.0}
+ - {name: API_SERVER_PORT, value: "8642"}
+ - {name: API_SERVER_CORS_ORIGINS, value: https://chat.hermes.bstein.dev}
+ volumeMounts:
+ - {name: home, mountPath: /opt/data}
+ - {name: provider-auth, mountPath: /shared-auth}
+ - {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
+ readinessProbe:
+ tcpSocket: {port: api}
+ initialDelaySeconds: 30
+ periodSeconds: 10
+ timeoutSeconds: 5
+ livenessProbe:
+ tcpSocket: {port: api}
+ initialDelaySeconds: 90
+ periodSeconds: 30
+ timeoutSeconds: 10
+ securityContext:
+ allowPrivilegeEscalation: false
+ runAsUser: 10000
+ runAsGroup: 10000
+ seccompProfile:
+ type: RuntimeDefault
+ resources:
+ requests: {cpu: 250m, memory: 512Mi}
+ limits: {cpu: "1", memory: 2Gi}
+ - name: webui
+ image: registry.bstein.dev/bstein/hermes-webui@sha256:a771858bd668d25e19c74864baea5425101c8cd5215d1ba3a312f3312ce6c5e1
+ imagePullPolicy: IfNotPresent
+ command: [/bin/sh, -ec]
+ args:
+ - |
+ api_key="$(sed -n 's/^API_SERVER_KEY=//p' /opt/data/.env | tail -n 1)"
+ test -n "${api_key}"
+ export API_SERVER_KEY="${api_key}"
+ export HERMES_WEBUI_GATEWAY_API_KEY="${api_key}"
+ exec /opt/hermes/.venv/bin/python /opt/hermes-webui/server.py
+ ports:
+ - {name: webui, containerPort: 8787, protocol: TCP}
+ env:
+ - {name: HERMES_HOME, value: /opt/data}
+ - {name: HERMES_AUTH_FILE, value: /shared-auth/auth.json}
+ - {name: HOME, value: /opt/data/home}
+ - {name: HERMES_WEBUI_AGENT_DIR, value: /opt/hermes}
+ - {name: HERMES_WEBUI_HOST, value: 0.0.0.0}
+ - {name: HERMES_WEBUI_PORT, value: "8787"}
+ - {name: HERMES_WEBUI_STATE_DIR, value: /opt/data/webui}
+ - {name: HERMES_WEBUI_DEFAULT_WORKSPACE, value: /opt/data/workspace}
+ - {name: HERMES_WEBUI_CHAT_BACKEND, value: gateway}
+ - {name: HERMES_WEBUI_GATEWAY_BASE_URL, value: http://127.0.0.1:8642}
+ - {name: HERMES_WEBUI_GATEWAY_USE_RUNS_API, value: "true"}
+ - {name: HERMES_WEBUI_SKIP_ONBOARDING, value: "1"}
+ - {name: HERMES_WEBUI_SECURE, value: "1"}
+ - {name: HERMES_WEBUI_ALLOWED_ORIGINS, value: https://chat.hermes.bstein.dev}
+ - {name: HERMES_WEBUI_TRUST_FORWARDED_HOST, value: "1"}
+ - {name: HERMES_WEBUI_TRUST_FORWARDED_PROTO, value: "1"}
+ volumeMounts:
+ - {name: home, mountPath: /opt/data}
+ - {name: provider-auth, mountPath: /shared-auth, readOnly: true}
+ - {name: tmp, mountPath: /tmp}
+ readinessProbe:
+ httpGet: {path: /health, port: webui}
+ initialDelaySeconds: 10
+ periodSeconds: 10
+ timeoutSeconds: 5
+ livenessProbe:
+ httpGet: {path: /health, port: webui}
+ initialDelaySeconds: 30
+ periodSeconds: 30
+ timeoutSeconds: 10
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop: [ALL]
+ readOnlyRootFilesystem: true
+ runAsUser: 10000
+ runAsGroup: 10000
+ seccompProfile:
+ type: RuntimeDefault
+ resources:
+ requests: {cpu: 100m, memory: 256Mi}
+ limits: {cpu: 750m, memory: 1Gi}
+ volumes:
+ - name: provider-auth
+ persistentVolumeClaim:
+ claimName: hermes-provider-auth
+ - name: config
+ configMap:
+ name: hermes-chat-config
+ - name: coordinator
+ configMap:
+ name: hermes-coordinator
+ defaultMode: 0555
+ - name: auth-patch
+ emptyDir: {}
+ - name: tmp
+ emptyDir:
+ sizeLimit: 256Mi
+ volumeClaimTemplates:
+ - metadata:
+ name: home
+ labels:
+ app: hermes-chat-tenant
+ spec:
+ accessModes: [ReadWriteOnce]
+ storageClassName: astreae
+ resources:
+ requests:
+ storage: 2Gi
diff --git a/services/hermes/configmap.yaml b/services/hermes/configmap.yaml
index 359049db9..1dc290b51 100644
--- a/services/hermes/configmap.yaml
+++ b/services/hermes/configmap.yaml
@@ -16,6 +16,10 @@ data:
fallback_providers:
- provider: openai-codex
model: gpt-5.6-terra
+ - provider: custom
+ model: qwen2.5:14b-instruct-q4_0
+ base_url: http://ollama.ai.svc.cluster.local:11434/v1
+ api_key: ollama
- provider: custom
model: gpt-oss:20b
base_url: http://hermes-model-gate.hermes.svc.cluster.local:11434/v1
@@ -23,6 +27,11 @@ data:
agent:
api_max_retries: 1
+ reasoning_effort: medium
+
+ model_catalog:
+ enabled: true
+ ttl_hours: 1
platform_toolsets:
cli:
@@ -72,13 +81,7 @@ data:
- "*kubectl describe secret*"
dashboard:
- public_url: https://agent.bstein.dev
- oauth:
- provider: self-hosted
- self_hosted:
- issuer: https://sso.bstein.dev/realms/atlas
- client_id: hermes-dashboard
- scopes: openid profile email groups
+ public_url: https://triage.hermes.bstein.dev
display:
compact: true
@@ -106,6 +109,11 @@ data:
You are Hermes running inside the Titan Kubernetes cluster as a supervised
testing and operations triage assistant.
+ This is the dedicated triage appliance at triage.hermes.bstein.dev. Keep
+ automated Ariadne intake and testing conversations here. Project delivery
+ and coding orchestration belong to agent.hermes.bstein.dev; general user
+ chat belongs to chat.hermes.bstein.dev.
+
Your strongest job is to follow the same evidence path Brad already uses:
Ariadne diagnosis first, then Jenkins logs and artifacts, Pushgateway
quality metrics, Flux state, Grafana dashboard context, and Kubernetes
diff --git a/services/hermes/deployment.yaml b/services/hermes/deployment.yaml
index 978beacb1..ea777ab04 100644
--- a/services/hermes/deployment.yaml
+++ b/services/hermes/deployment.yaml
@@ -23,8 +23,8 @@ spec:
ai.bstein.dev/frontend-fix: scope PTY attachment by selected conversation
ai.bstein.dev/model: anthropic/claude-opus-5, falling back to openai-codex/gpt-5.6-terra then local gpt-oss:20b
ai.bstein.dev/role: testing-triage
- ai.bstein.dev/placement: arm64 gateway lane (rpi5 preferred)
- ai.bstein.dev/config-rev: "20260804-root-operator-docs"
+ ai.bstein.dev/placement: titan-21 preferred, Jetson preferred, arm64 fallback
+ ai.bstein.dev/config-rev: "20260808-dedicated-triage"
# The Anthropic credential comes from Vault rather than a manually
# created Secret. The role is declared in
# services/vault/scripts/vault_k8s_auth_configure.sh and bound to
@@ -76,11 +76,25 @@ spec:
- titan-19
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
+ preference:
+ matchExpressions:
+ - key: kubernetes.io/hostname
+ operator: In
+ values:
+ - titan-21
+ - weight: 90
+ preference:
+ matchExpressions:
+ - key: jetson
+ operator: In
+ values:
+ - "true"
+ - weight: 80
preference:
matchExpressions:
- key: atlas.bstein.dev/spillover
operator: DoesNotExist
- - weight: 90
+ - weight: 60
preference:
matchExpressions:
- key: hardware
@@ -158,7 +172,13 @@ spec:
mv /opt/data/.env.tmp /opt/data/.env
fi
chmod 0600 /opt/data/.env
+ mkdir -p /shared-auth
+ if [ ! -s /shared-auth/auth.json ] && [ -s /opt/data/auth.json ]; then
+ cp /opt/data/auth.json /shared-auth/auth.json
+ chmod 0600 /shared-auth/auth.json
+ fi
chown -R 10000:10000 /opt/data
+ chown -R 10000:10000 /shared-auth
securityContext:
runAsUser: 0
runAsGroup: 0
@@ -169,6 +189,8 @@ spec:
mountPath: /config
- name: operator-guide
mountPath: /guide
+ - name: provider-auth
+ mountPath: /shared-auth
resources:
requests:
cpu: 25m
@@ -176,6 +198,33 @@ spec:
limits:
cpu: 100m
memory: 64Mi
+ - name: patch-auth
+ image: registry.bstein.dev/bstein/hermes-agent@sha256:15c5c538c0b58686af2e54e10bc870b23284789d485a609349df24ed3053622f
+ imagePullPolicy: IfNotPresent
+ command:
+ - /opt/hermes/.venv/bin/python
+ - /opt/coordinator/patch_hermes_auth.py
+ - /opt/hermes/hermes_cli/auth.py
+ - /patched/auth.py
+ securityContext:
+ allowPrivilegeEscalation: false
+ runAsUser: 10000
+ runAsGroup: 10000
+ seccompProfile:
+ type: RuntimeDefault
+ volumeMounts:
+ - name: coordinator
+ mountPath: /opt/coordinator
+ readOnly: true
+ - name: auth-patch
+ mountPath: /patched
+ resources:
+ requests:
+ cpu: 25m
+ memory: 64Mi
+ limits:
+ cpu: 100m
+ memory: 128Mi
- name: install-kubectl
image: bitnami/kubectl@sha256:554ab88b1858e8424c55de37ad417b16f2a0e65d1607aa0f3fe3ce9b9f10b131
imagePullPolicy: IfNotPresent
@@ -217,6 +266,8 @@ spec:
env:
- name: HERMES_HOME
value: /opt/data
+ - name: HERMES_AUTH_FILE
+ value: /shared-auth/auth.json
- name: HOME
value: /opt/data/home
- name: PATH
@@ -228,13 +279,7 @@ spec:
- name: HERMES_DASHBOARD_PORT
value: "9119"
- name: HERMES_DASHBOARD_PUBLIC_URL
- value: https://agent.bstein.dev
- - name: HERMES_DASHBOARD_OIDC_ISSUER
- value: https://sso.bstein.dev/realms/atlas
- - name: HERMES_DASHBOARD_OIDC_CLIENT_ID
- value: hermes-dashboard
- - name: HERMES_DASHBOARD_OIDC_SCOPES
- value: openid profile email groups
+ value: https://triage.hermes.bstein.dev
- name: API_SERVER_ENABLED
value: "true"
- name: API_SERVER_HOST
@@ -242,7 +287,7 @@ spec:
- name: API_SERVER_PORT
value: "8642"
- name: API_SERVER_CORS_ORIGINS
- value: https://agent.bstein.dev
+ value: https://triage.hermes.bstein.dev
- name: VICTORIA_METRICS_URL
value: http://victoria-metrics-single-server.monitoring.svc.cluster.local:8428
- name: ARIADNE_BASE_URL
@@ -264,6 +309,11 @@ spec:
volumeMounts:
- name: home
mountPath: /opt/data
+ - name: provider-auth
+ mountPath: /shared-auth
+ - name: auth-patch
+ mountPath: /opt/hermes/hermes_cli/auth.py
+ subPath: auth.py
- name: tools
mountPath: /usr/local/bin/kubectl
subPath: kubectl
@@ -304,6 +354,9 @@ spec:
- name: home
persistentVolumeClaim:
claimName: hermes-home
+ - name: provider-auth
+ persistentVolumeClaim:
+ claimName: hermes-provider-auth
- name: config
configMap:
name: hermes-config
@@ -312,6 +365,12 @@ spec:
name: hermes-operator-guide
- name: tools
emptyDir: {}
+ - name: coordinator
+ configMap:
+ name: hermes-coordinator
+ defaultMode: 0555
+ - name: auth-patch
+ emptyDir: {}
- name: triage-skill
configMap:
name: hermes-triage-skill
diff --git a/services/hermes/kustomization.yaml b/services/hermes/kustomization.yaml
index 6033f3e5a..ea2258a1c 100644
--- a/services/hermes/kustomization.yaml
+++ b/services/hermes/kustomization.yaml
@@ -6,6 +6,8 @@ resources:
- namespace.yaml
- vault-serviceaccount.yaml
- configmap.yaml
+ - agent-configmap.yaml
+ - chat-configmap.yaml
- rbac.yaml
- pvc.yaml
- model-gate-rbac.yaml
@@ -16,6 +18,9 @@ resources:
- networkpolicy.yaml
- ollama-deployment.yaml
- deployment.yaml
+ - agent-deployment.yaml
+ - chat-statefulset.yaml
+ - chat-router.yaml
- service.yaml
- oauth2-proxy.yaml
- agent-certificate.yaml
@@ -28,6 +33,26 @@ configMapGenerator:
- OPERATOR-RUNBOOK.md=NOTES.md
options:
disableNameSuffixHash: true
+ - name: hermes-coordinator
+ namespace: hermes
+ files:
+ - gitea_askpass.sh=scripts/gitea_askpass.sh
+ - herdr_dispatch.py=scripts/herdr_dispatch.py
+ - hermes_coordinator.py=scripts/hermes_coordinator.py
+ - hermes_model_routing.py=scripts/hermes_model_routing.py
+ - patch_hermes_auth.py=scripts/patch_hermes_auth.py
+ options:
+ disableNameSuffixHash: true
+ - name: hermes-chat-router-source
+ namespace: hermes
+ files:
+ - main.go=router/main.go
+ - main_test.go=router/main_test.go
+ - telegram.go=router/telegram.go
+ - telegram_test.go=router/telegram_test.go
+ - web.go=router/web.go
+ options:
+ disableNameSuffixHash: true
- name: hermes-triage-skill
namespace: hermes
files:
diff --git a/services/hermes/networkpolicy.yaml b/services/hermes/networkpolicy.yaml
index 57bfa577f..6f70516f4 100644
--- a/services/hermes/networkpolicy.yaml
+++ b/services/hermes/networkpolicy.yaml
@@ -28,3 +28,287 @@ spec:
ports:
- protocol: TCP
port: 11434
+---
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+ name: hermes-triage-ingress
+ namespace: hermes
+spec:
+ podSelector:
+ matchLabels:
+ app: hermes
+ policyTypes: [Ingress]
+ ingress:
+ - from:
+ - podSelector:
+ matchLabels:
+ app: oauth2-proxy-hermes-triage
+ ports:
+ - {protocol: TCP, port: 9119}
+ - from:
+ - namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: maintenance
+ podSelector:
+ matchLabels:
+ app: ariadne
+ ports:
+ - {protocol: TCP, port: 8642}
+---
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+ name: hermes-agent-isolation
+ namespace: hermes
+spec:
+ podSelector:
+ matchLabels:
+ app: hermes-agent
+ policyTypes: [Ingress, Egress]
+ ingress:
+ - from:
+ - podSelector:
+ matchLabels:
+ app: oauth2-proxy-hermes-agent
+ ports:
+ - {protocol: TCP, port: 9119}
+ egress:
+ - to:
+ - namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: kube-system
+ podSelector:
+ matchLabels:
+ k8s-app: kube-dns
+ ports:
+ - {protocol: UDP, port: 53}
+ - {protocol: TCP, port: 53}
+ - to:
+ - namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: vault
+ podSelector:
+ matchLabels:
+ app: vault
+ ports:
+ - {protocol: TCP, port: 8200}
+ - to:
+ - namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: ai
+ podSelector:
+ matchLabels:
+ app: ollama
+ ports:
+ - {protocol: TCP, port: 11434}
+ - to:
+ - podSelector:
+ matchLabels:
+ app: hermes-model-gate
+ ports:
+ - {protocol: TCP, port: 8080}
+ - to:
+ - namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: traefik
+ podSelector:
+ matchLabels:
+ app: traefik
+ ports:
+ - {protocol: TCP, port: 443}
+ - to:
+ - ipBlock:
+ cidr: 0.0.0.0/0
+ except:
+ - 10.0.0.0/8
+ - 100.64.0.0/10
+ - 127.0.0.0/8
+ - 169.254.0.0/16
+ - 172.16.0.0/12
+ - 192.168.0.0/16
+---
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+ name: hermes-chat-tenant-isolation
+ namespace: hermes
+spec:
+ podSelector:
+ matchLabels:
+ app: hermes-chat-tenant
+ policyTypes: [Ingress, Egress]
+ ingress:
+ - from:
+ - podSelector:
+ matchLabels:
+ app: hermes-chat-router
+ ports:
+ - {protocol: TCP, port: 8787}
+ - {protocol: TCP, port: 8642}
+ egress:
+ - to:
+ - namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: kube-system
+ podSelector:
+ matchLabels:
+ k8s-app: kube-dns
+ ports:
+ - {protocol: UDP, port: 53}
+ - {protocol: TCP, port: 53}
+ - to:
+ - namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: vault
+ podSelector:
+ matchLabels:
+ app: vault
+ ports:
+ - {protocol: TCP, port: 8200}
+ - to:
+ - namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: ai
+ podSelector:
+ matchLabels:
+ app: ollama
+ ports:
+ - {protocol: TCP, port: 11434}
+ - to:
+ - podSelector:
+ matchLabels:
+ app: hermes-model-gate
+ ports:
+ - {protocol: TCP, port: 8080}
+ - to:
+ - ipBlock:
+ cidr: 0.0.0.0/0
+ except:
+ - 10.0.0.0/8
+ - 100.64.0.0/10
+ - 127.0.0.0/8
+ - 169.254.0.0/16
+ - 172.16.0.0/12
+ - 192.168.0.0/16
+---
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+ name: hermes-chat-router-isolation
+ namespace: hermes
+spec:
+ podSelector:
+ matchLabels:
+ app: hermes-chat-router
+ policyTypes: [Ingress, Egress]
+ ingress:
+ - from:
+ - podSelector:
+ matchLabels:
+ app: oauth2-proxy-hermes-chat
+ ports:
+ - {protocol: TCP, port: 8080}
+ egress:
+ - to:
+ - namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: kube-system
+ podSelector:
+ matchLabels:
+ k8s-app: kube-dns
+ ports:
+ - {protocol: UDP, port: 53}
+ - {protocol: TCP, port: 53}
+ - to:
+ - podSelector:
+ matchLabels:
+ app: hermes-chat-tenant
+ ports:
+ - {protocol: TCP, port: 8787}
+ - {protocol: TCP, port: 8642}
+ - to:
+ - namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: vault
+ podSelector:
+ matchLabels:
+ app: vault
+ ports:
+ - {protocol: TCP, port: 8200}
+ - to:
+ - ipBlock:
+ cidr: 0.0.0.0/0
+ except:
+ - 10.0.0.0/8
+ - 100.64.0.0/10
+ - 127.0.0.0/8
+ - 169.254.0.0/16
+ - 172.16.0.0/12
+ - 192.168.0.0/16
+ ports:
+ - {protocol: TCP, port: 443}
+---
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+ name: hermes-oauth2-proxies
+ namespace: hermes
+spec:
+ podSelector:
+ matchExpressions:
+ - key: app
+ operator: In
+ values:
+ - oauth2-proxy-hermes-agent
+ - oauth2-proxy-hermes-chat
+ - oauth2-proxy-hermes-triage
+ policyTypes: [Ingress, Egress]
+ ingress:
+ - from:
+ - namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: traefik
+ podSelector:
+ matchLabels:
+ app: traefik
+ ports:
+ - {protocol: TCP, port: 4180}
+ egress:
+ - to:
+ - namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: kube-system
+ podSelector:
+ matchLabels:
+ k8s-app: kube-dns
+ ports:
+ - {protocol: UDP, port: 53}
+ - {protocol: TCP, port: 53}
+ - to:
+ - namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: vault
+ podSelector:
+ matchLabels:
+ app: vault
+ ports:
+ - {protocol: TCP, port: 8200}
+ - to:
+ - namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: traefik
+ podSelector:
+ matchLabels:
+ app: traefik
+ ports:
+ - {protocol: TCP, port: 443}
+ - to:
+ - podSelector:
+ matchExpressions:
+ - key: app
+ operator: In
+ values: [hermes, hermes-agent, hermes-chat-router]
+ ports:
+ - {protocol: TCP, port: 9119}
+ - {protocol: TCP, port: 8080}
diff --git a/services/hermes/oauth2-proxy.yaml b/services/hermes/oauth2-proxy.yaml
index d9971f0d6..4bfaee925 100644
--- a/services/hermes/oauth2-proxy.yaml
+++ b/services/hermes/oauth2-proxy.yaml
@@ -2,7 +2,7 @@
apiVersion: v1
kind: ConfigMap
metadata:
- name: hermes-operator-allowlist
+ name: hermes-owner-allowlist
namespace: hermes
data:
allowed-emails: |
@@ -11,85 +11,67 @@ data:
apiVersion: v1
kind: Service
metadata:
- name: oauth2-proxy-hermes
+ name: oauth2-proxy-hermes-agent
namespace: hermes
- labels:
- app: oauth2-proxy-hermes
spec:
selector:
- app: oauth2-proxy-hermes
+ app: oauth2-proxy-hermes-agent
ports:
- - name: http
- port: 80
- targetPort: http
+ - {name: http, port: 80, targetPort: http}
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: oauth2-proxy-hermes-triage
+ namespace: hermes
+spec:
+ selector:
+ app: oauth2-proxy-hermes-triage
+ ports:
+ - {name: http, port: 80, targetPort: http}
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: oauth2-proxy-hermes-chat
+ namespace: hermes
+spec:
+ selector:
+ app: oauth2-proxy-hermes-chat
+ ports:
+ - {name: http, port: 80, targetPort: http}
---
apiVersion: apps/v1
kind: Deployment
metadata:
- name: oauth2-proxy-hermes
+ name: oauth2-proxy-hermes-agent
namespace: hermes
labels:
- app: oauth2-proxy-hermes
+ app: oauth2-proxy-hermes-agent
spec:
replicas: 1
revisionHistoryLimit: 2
selector:
matchLabels:
- app: oauth2-proxy-hermes
+ app: oauth2-proxy-hermes-agent
template:
metadata:
labels:
- app: oauth2-proxy-hermes
+ app: oauth2-proxy-hermes-agent
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/agent-pre-populate-only: "true"
- vault.hashicorp.com/role: hermes
- vault.hashicorp.com/agent-inject-secret-oidc-config: kv/data/atlas/hermes/operator-oidc
+ vault.hashicorp.com/role: hermes-agent
+ vault.hashicorp.com/agent-inject-secret-oidc-config: kv/data/atlas/hermes/agent-oidc
vault.hashicorp.com/agent-inject-template-oidc-config: |
- {{- with secret "kv/data/atlas/hermes/operator-oidc" -}}
+ {{- with secret "kv/data/atlas/hermes/agent-oidc" -}}
client_id = "{{ .Data.data.client_id }}"
client_secret = "{{ .Data.data.client_secret }}"
cookie_secret = "{{ .Data.data.cookie_secret }}"
{{- end -}}
spec:
- serviceAccountName: hermes-vault
+ serviceAccountName: hermes-agent
automountServiceAccountToken: true
- affinity:
- nodeAffinity:
- requiredDuringSchedulingIgnoredDuringExecution:
- nodeSelectorTerms:
- - matchExpressions:
- - key: kubernetes.io/arch
- operator: In
- values:
- - arm64
- - key: node-role.kubernetes.io/worker
- operator: In
- values:
- - "true"
- - key: kubernetes.io/hostname
- operator: NotIn
- values:
- - titan-13
- - titan-15
- - titan-17
- - titan-18
- - titan-19
- preferredDuringSchedulingIgnoredDuringExecution:
- - weight: 90
- preference:
- matchExpressions:
- - key: hardware
- operator: In
- values:
- - rpi5
- - weight: 50
- preference:
- matchExpressions:
- - key: hardware
- operator: In
- values:
- - rpi4
containers:
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0@sha256:dcb6ff8dd21bf3058f6a22c6fa385fa5b897a9cd3914c88a2cc2bb0a85f8065d
@@ -97,65 +79,235 @@ spec:
args:
- --provider=oidc
- --config=/vault/secrets/oidc-config
- - --redirect-url=https://agent.bstein.dev/oauth2/callback
+ - --redirect-url=https://agent.hermes.bstein.dev/oauth2/callback
- --oidc-issuer-url=https://sso.bstein.dev/realms/atlas
+ - --user-id-claim=sub
- --code-challenge-method=S256
- - --scope=openid profile email
+ - --scope=openid profile email groups
- --email-domain=*
- --authenticated-emails-file=/etc/oauth2-proxy/allowed-emails
- --set-xauthrequest=true
+ - --pass-user-headers=true
+ - --pass-basic-auth=false
+ - --proxy-websockets=true
- --session-cookie-minimal=true
+ - --cookie-name=__Host-hermes_agent
+ - --cookie-path=/
- --cookie-secure=true
- --cookie-samesite=lax
- - --cookie-refresh=0
+ - --cookie-refresh=1h
- --cookie-expire=8h
- - --upstream=http://hermes.hermes.svc.cluster.local:9119
+ - --upstream=http://hermes-agent.hermes.svc.cluster.local:9119
- --http-address=0.0.0.0:4180
- --skip-provider-button=true
- - --skip-jwt-bearer-tokens=true
- - --cookie-domain=agent.bstein.dev
- --reverse-proxy=true
+ - --trusted-proxy-ip=10.42.0.0/16
ports:
- - name: http
- containerPort: 4180
+ - {name: http, containerPort: 4180}
readinessProbe:
- httpGet:
- path: /ping
- port: http
+ httpGet: {path: /ping, port: http}
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
- httpGet:
- path: /ping
- port: http
+ httpGet: {path: /ping, port: http}
initialDelaySeconds: 20
periodSeconds: 20
securityContext:
allowPrivilegeEscalation: false
capabilities:
- drop:
- - ALL
+ drop: [ALL]
readOnlyRootFilesystem: true
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
resources:
- requests:
- cpu: 25m
- memory: 64Mi
- limits:
- cpu: 250m
- memory: 256Mi
+ requests: {cpu: 25m, memory: 64Mi}
+ limits: {cpu: 250m, memory: 256Mi}
volumeMounts:
- - name: allowlist
- mountPath: /etc/oauth2-proxy
- readOnly: true
- - name: tmp
- mountPath: /tmp
+ - {name: allowlist, mountPath: /etc/oauth2-proxy, readOnly: true}
+ - {name: tmp, mountPath: /tmp}
volumes:
- name: allowlist
configMap:
- name: hermes-operator-allowlist
+ name: hermes-owner-allowlist
- name: tmp
- emptyDir:
- sizeLimit: 64Mi
+ emptyDir: {sizeLimit: 64Mi}
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: oauth2-proxy-hermes-triage
+ namespace: hermes
+ labels:
+ app: oauth2-proxy-hermes-triage
+spec:
+ replicas: 1
+ revisionHistoryLimit: 2
+ selector:
+ matchLabels:
+ app: oauth2-proxy-hermes-triage
+ template:
+ metadata:
+ labels:
+ app: oauth2-proxy-hermes-triage
+ annotations:
+ vault.hashicorp.com/agent-inject: "true"
+ vault.hashicorp.com/agent-pre-populate-only: "true"
+ vault.hashicorp.com/role: hermes
+ vault.hashicorp.com/agent-inject-secret-oidc-config: kv/data/atlas/hermes/triage-oidc
+ vault.hashicorp.com/agent-inject-template-oidc-config: |
+ {{- with secret "kv/data/atlas/hermes/triage-oidc" -}}
+ client_id = "{{ .Data.data.client_id }}"
+ client_secret = "{{ .Data.data.client_secret }}"
+ cookie_secret = "{{ .Data.data.cookie_secret }}"
+ {{- end -}}
+ spec:
+ serviceAccountName: hermes-vault
+ automountServiceAccountToken: true
+ containers:
+ - name: oauth2-proxy
+ image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0@sha256:dcb6ff8dd21bf3058f6a22c6fa385fa5b897a9cd3914c88a2cc2bb0a85f8065d
+ imagePullPolicy: IfNotPresent
+ args:
+ - --provider=oidc
+ - --config=/vault/secrets/oidc-config
+ - --redirect-url=https://triage.hermes.bstein.dev/oauth2/callback
+ - --oidc-issuer-url=https://sso.bstein.dev/realms/atlas
+ - --user-id-claim=sub
+ - --code-challenge-method=S256
+ - --scope=openid profile email groups
+ - --email-domain=*
+ - --authenticated-emails-file=/etc/oauth2-proxy/allowed-emails
+ - --set-xauthrequest=true
+ - --pass-user-headers=true
+ - --pass-basic-auth=false
+ - --proxy-websockets=true
+ - --session-cookie-minimal=true
+ - --cookie-name=__Host-hermes_triage
+ - --cookie-path=/
+ - --cookie-secure=true
+ - --cookie-samesite=lax
+ - --cookie-refresh=1h
+ - --cookie-expire=8h
+ - --upstream=http://hermes-triage.hermes.svc.cluster.local:9119
+ - --http-address=0.0.0.0:4180
+ - --skip-provider-button=true
+ - --reverse-proxy=true
+ - --trusted-proxy-ip=10.42.0.0/16
+ ports:
+ - {name: http, containerPort: 4180}
+ readinessProbe:
+ httpGet: {path: /ping, port: http}
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ livenessProbe:
+ httpGet: {path: /ping, port: http}
+ initialDelaySeconds: 20
+ periodSeconds: 20
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop: [ALL]
+ readOnlyRootFilesystem: true
+ runAsNonRoot: true
+ seccompProfile:
+ type: RuntimeDefault
+ resources:
+ requests: {cpu: 25m, memory: 64Mi}
+ limits: {cpu: 250m, memory: 256Mi}
+ volumeMounts:
+ - {name: allowlist, mountPath: /etc/oauth2-proxy, readOnly: true}
+ - {name: tmp, mountPath: /tmp}
+ volumes:
+ - name: allowlist
+ configMap:
+ name: hermes-owner-allowlist
+ - name: tmp
+ emptyDir: {sizeLimit: 64Mi}
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: oauth2-proxy-hermes-chat
+ namespace: hermes
+ labels:
+ app: oauth2-proxy-hermes-chat
+spec:
+ replicas: 1
+ revisionHistoryLimit: 2
+ selector:
+ matchLabels:
+ app: oauth2-proxy-hermes-chat
+ template:
+ metadata:
+ labels:
+ app: oauth2-proxy-hermes-chat
+ annotations:
+ vault.hashicorp.com/agent-inject: "true"
+ vault.hashicorp.com/agent-pre-populate-only: "true"
+ vault.hashicorp.com/role: hermes-chat
+ vault.hashicorp.com/agent-inject-secret-oidc-config: kv/data/atlas/hermes/chat-oidc
+ vault.hashicorp.com/agent-inject-template-oidc-config: |
+ {{- with secret "kv/data/atlas/hermes/chat-oidc" -}}
+ client_id = "{{ .Data.data.client_id }}"
+ client_secret = "{{ .Data.data.client_secret }}"
+ cookie_secret = "{{ .Data.data.cookie_secret }}"
+ {{- end -}}
+ spec:
+ serviceAccountName: hermes-chat
+ automountServiceAccountToken: true
+ containers:
+ - name: oauth2-proxy
+ image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0@sha256:dcb6ff8dd21bf3058f6a22c6fa385fa5b897a9cd3914c88a2cc2bb0a85f8065d
+ imagePullPolicy: IfNotPresent
+ args:
+ - --provider=oidc
+ - --config=/vault/secrets/oidc-config
+ - --redirect-url=https://chat.hermes.bstein.dev/oauth2/callback
+ - --oidc-issuer-url=https://sso.bstein.dev/realms/atlas
+ - --user-id-claim=sub
+ - --code-challenge-method=S256
+ - --scope=openid profile email groups
+ - --email-domain=*
+ - --set-xauthrequest=true
+ - --pass-user-headers=true
+ - --pass-basic-auth=false
+ - --proxy-websockets=true
+ - --session-cookie-minimal=true
+ - --cookie-name=__Host-hermes_chat
+ - --cookie-path=/
+ - --cookie-secure=true
+ - --cookie-samesite=lax
+ - --cookie-refresh=1h
+ - --cookie-expire=8h
+ - --upstream=http://hermes-chat-router.hermes.svc.cluster.local:8080
+ - --http-address=0.0.0.0:4180
+ - --skip-provider-button=true
+ - --reverse-proxy=true
+ - --trusted-proxy-ip=10.42.0.0/16
+ ports:
+ - {name: http, containerPort: 4180}
+ readinessProbe:
+ httpGet: {path: /ping, port: http}
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ livenessProbe:
+ httpGet: {path: /ping, port: http}
+ initialDelaySeconds: 20
+ periodSeconds: 20
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop: [ALL]
+ readOnlyRootFilesystem: true
+ runAsNonRoot: true
+ seccompProfile:
+ type: RuntimeDefault
+ resources:
+ requests: {cpu: 25m, memory: 64Mi}
+ limits: {cpu: 250m, memory: 256Mi}
+ volumeMounts:
+ - {name: tmp, mountPath: /tmp}
+ volumes:
+ - name: tmp
+ emptyDir: {sizeLimit: 64Mi}
diff --git a/services/hermes/pvc.yaml b/services/hermes/pvc.yaml
index 9ff1830fb..3a02cbfb7 100644
--- a/services/hermes/pvc.yaml
+++ b/services/hermes/pvc.yaml
@@ -16,6 +16,51 @@ spec:
---
apiVersion: v1
kind: PersistentVolumeClaim
+metadata:
+ name: hermes-agent-home
+ namespace: hermes
+ labels:
+ app: hermes-agent
+spec:
+ accessModes:
+ - ReadWriteOnce
+ storageClassName: astreae
+ resources:
+ requests:
+ storage: 20Gi
+---
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: hermes-provider-auth
+ namespace: hermes
+ labels:
+ app.kubernetes.io/part-of: hermes
+spec:
+ accessModes:
+ - ReadWriteMany
+ storageClassName: astreae
+ resources:
+ requests:
+ storage: 1Gi
+---
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: hermes-chat-router-state
+ namespace: hermes
+ labels:
+ app: hermes-chat-router
+spec:
+ accessModes:
+ - ReadWriteOnce
+ storageClassName: astreae
+ resources:
+ requests:
+ storage: 1Gi
+---
+apiVersion: v1
+kind: PersistentVolumeClaim
metadata:
name: hermes-models
namespace: hermes
diff --git a/services/hermes/router/main.go b/services/hermes/router/main.go
new file mode 100644
index 000000000..3a49f331d
--- /dev/null
+++ b/services/hermes/router/main.go
@@ -0,0 +1,342 @@
+// Hermes chat tenant router assigns each Keycloak subject to one isolated
+// Hermes pod and exposes only the user-facing parts of Hermes WebUI.
+package main
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base32"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+type linkRecord struct {
+ Slot int `json:"slot"`
+ ExpiresAt int64 `json:"expires_at"`
+}
+
+type tenantState struct {
+ Salt string `json:"salt"`
+ Assignments map[string]int `json:"assignments"`
+ Telegram map[string]int `json:"telegram,omitempty"`
+ LinkCodes map[string]linkRecord `json:"link_codes,omitempty"`
+ TelegramOffset int64 `json:"telegram_offset,omitempty"`
+}
+
+type tenantRouter struct {
+ mu sync.Mutex
+ state tenantState
+ statePath string
+ slots int
+ backendURL func(int) string
+ backendAPIURL func(int) string
+ now func() time.Time
+ telegram *telegramBot
+}
+
+var deniedPrefixes = []string{
+ "/api/admin", "/api/commands/exec", "/api/config", "/api/console", "/api/credentials",
+ "/api/cron", "/api/curator", "/api/dashboard/config", "/api/env",
+ "/api/escape", "/api/extensions", "/api/file", "/api/folder",
+ "/api/gateway", "/api/git", "/api/git-info", "/api/health/restart", "/api/kanban", "/api/logs", "/api/mcp",
+ "/api/memory", "/api/messaging", "/api/notes", "/api/ops",
+ "/api/oauth", "/api/onboarding/oauth", "/api/pairing", "/api/plugins", "/api/profiles", "/api/providers",
+ "/api/rollback", "/api/shutdown", "/api/skills", "/api/terminal",
+ "/api/tools", "/api/updates", "/api/webhooks", "/api/wiki",
+ "/api/workspace", "/api/workspaces",
+}
+
+func newTenantRouter(statePath string, slots int, backendURL func(int) string) (*tenantRouter, error) {
+ if slots < 1 {
+ return nil, fmt.Errorf("tenant slots must be positive")
+ }
+ router := &tenantRouter{
+ statePath: statePath,
+ slots: slots,
+ backendURL: backendURL,
+ now: time.Now,
+ state: tenantState{
+ Assignments: map[string]int{},
+ Telegram: map[string]int{},
+ LinkCodes: map[string]linkRecord{},
+ },
+ }
+ content, err := os.ReadFile(statePath)
+ if err == nil {
+ if err := json.Unmarshal(content, &router.state); err != nil {
+ return nil, fmt.Errorf("decode tenant state: %w", err)
+ }
+ } else if !os.IsNotExist(err) {
+ return nil, fmt.Errorf("read tenant state: %w", err)
+ }
+ if router.state.Assignments == nil {
+ router.state.Assignments = map[string]int{}
+ }
+ if router.state.Telegram == nil {
+ router.state.Telegram = map[string]int{}
+ }
+ if router.state.LinkCodes == nil {
+ router.state.LinkCodes = map[string]linkRecord{}
+ }
+ if router.state.Salt == "" {
+ salt := make([]byte, 32)
+ if _, err := rand.Read(salt); err != nil {
+ return nil, fmt.Errorf("create tenant salt: %w", err)
+ }
+ router.state.Salt = hex.EncodeToString(salt)
+ if err := router.saveLocked(); err != nil {
+ return nil, err
+ }
+ }
+ return router, nil
+}
+
+func (router *tenantRouter) saveLocked() error {
+ content, err := json.MarshalIndent(router.state, "", " ")
+ if err != nil {
+ return fmt.Errorf("encode tenant state: %w", err)
+ }
+ if err := os.MkdirAll(filepath.Dir(router.statePath), 0700); err != nil {
+ return fmt.Errorf("create tenant state directory: %w", err)
+ }
+ temporary := router.statePath + ".tmp"
+ if err := os.WriteFile(temporary, append(content, '\n'), 0600); err != nil {
+ return fmt.Errorf("write tenant state: %w", err)
+ }
+ if err := os.Rename(temporary, router.statePath); err != nil {
+ return fmt.Errorf("replace tenant state: %w", err)
+ }
+ return nil
+}
+
+func (router *tenantRouter) identityHash(kind, value string) string {
+ digest := sha256.Sum256([]byte(router.state.Salt + "\x00" + kind + "\x00" + value))
+ return hex.EncodeToString(digest[:])
+}
+
+func (router *tenantRouter) slotFor(subject string) (int, error) {
+ router.mu.Lock()
+ defer router.mu.Unlock()
+ identity := router.identityHash("keycloak", subject)
+ if slot, ok := router.state.Assignments[identity]; ok {
+ return slot, nil
+ }
+ used := make(map[int]bool, len(router.state.Assignments))
+ for _, slot := range router.state.Assignments {
+ used[slot] = true
+ }
+ for slot := 0; slot < router.slots; slot++ {
+ if used[slot] {
+ continue
+ }
+ router.state.Assignments[identity] = slot
+ if err := router.saveLocked(); err != nil {
+ delete(router.state.Assignments, identity)
+ return 0, err
+ }
+ return slot, nil
+ }
+ return 0, fmt.Errorf("all isolated chat slots are assigned")
+}
+
+func (router *tenantRouter) createLink(subject string) (string, time.Time, error) {
+ slot, err := router.slotFor(subject)
+ if err != nil {
+ return "", time.Time{}, err
+ }
+ raw := make([]byte, 5)
+ if _, err := rand.Read(raw); err != nil {
+ return "", time.Time{}, fmt.Errorf("create link code: %w", err)
+ }
+ code := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw)
+ expires := router.now().Add(10 * time.Minute)
+ router.mu.Lock()
+ defer router.mu.Unlock()
+ for digest, record := range router.state.LinkCodes {
+ if record.Slot == slot || record.ExpiresAt <= router.now().Unix() {
+ delete(router.state.LinkCodes, digest)
+ }
+ }
+ digest := router.identityHash("link", strings.ToUpper(code))
+ router.state.LinkCodes[digest] = linkRecord{Slot: slot, ExpiresAt: expires.Unix()}
+ if err := router.saveLocked(); err != nil {
+ delete(router.state.LinkCodes, digest)
+ return "", time.Time{}, err
+ }
+ return code, expires, nil
+}
+
+func (router *tenantRouter) consumeLink(telegramUser, code string) (int, error) {
+ router.mu.Lock()
+ defer router.mu.Unlock()
+ digest := router.identityHash("link", strings.ToUpper(strings.TrimSpace(code)))
+ record, ok := router.state.LinkCodes[digest]
+ if !ok || record.ExpiresAt <= router.now().Unix() {
+ delete(router.state.LinkCodes, digest)
+ return 0, fmt.Errorf("link code is invalid or expired")
+ }
+ delete(router.state.LinkCodes, digest)
+ router.state.Telegram[router.identityHash("telegram", telegramUser)] = record.Slot
+ if err := router.saveLocked(); err != nil {
+ return 0, err
+ }
+ return record.Slot, nil
+}
+
+func (router *tenantRouter) telegramSlot(telegramUser string) (int, bool) {
+ router.mu.Lock()
+ defer router.mu.Unlock()
+ slot, ok := router.state.Telegram[router.identityHash("telegram", telegramUser)]
+ return slot, ok
+}
+
+func (router *tenantRouter) telegramLinked(subject string) (bool, error) {
+ slot, err := router.slotFor(subject)
+ if err != nil {
+ return false, err
+ }
+ router.mu.Lock()
+ defer router.mu.Unlock()
+ for _, linkedSlot := range router.state.Telegram {
+ if linkedSlot == slot {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+func (router *tenantRouter) unlinkTelegram(subject string) error {
+ slot, err := router.slotFor(subject)
+ if err != nil {
+ return err
+ }
+ router.mu.Lock()
+ defer router.mu.Unlock()
+ for identity, linkedSlot := range router.state.Telegram {
+ if linkedSlot == slot {
+ delete(router.state.Telegram, identity)
+ }
+ }
+ for digest, record := range router.state.LinkCodes {
+ if record.Slot == slot {
+ delete(router.state.LinkCodes, digest)
+ }
+ }
+ return router.saveLocked()
+}
+
+func authenticatedSubject(request *http.Request) string {
+ for _, header := range []string{"X-Forwarded-User", "X-Auth-Request-User"} {
+ if subject := strings.TrimSpace(request.Header.Get(header)); subject != "" {
+ return subject
+ }
+ }
+ return ""
+}
+
+func pathDenied(path string) bool {
+ for _, prefix := range deniedPrefixes {
+ if path == prefix || strings.HasPrefix(path, prefix+"/") {
+ return true
+ }
+ }
+ return false
+}
+
+func (router *tenantRouter) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
+ if request.URL.Path == "/healthz" {
+ writer.Header().Set("Content-Type", "text/plain")
+ _, _ = writer.Write([]byte("ok\n"))
+ return
+ }
+ subject := authenticatedSubject(request)
+ if subject == "" {
+ http.Error(writer, "authenticated identity required", http.StatusUnauthorized)
+ return
+ }
+ if router.serveTelegramWeb(writer, request, subject) {
+ return
+ }
+ if pathDenied(request.URL.Path) {
+ http.Error(writer, "chat administration is disabled", http.StatusForbidden)
+ return
+ }
+ slot, err := router.slotFor(subject)
+ if err != nil {
+ http.Error(writer, err.Error(), http.StatusServiceUnavailable)
+ return
+ }
+ target, err := url.Parse(router.backendURL(slot))
+ if err != nil {
+ http.Error(writer, "invalid tenant backend", http.StatusInternalServerError)
+ return
+ }
+ proxy := httputil.NewSingleHostReverseProxy(target)
+ proxy.ModifyResponse = injectChatBridge
+ proxy.ErrorHandler = func(writer http.ResponseWriter, _ *http.Request, proxyErr error) {
+ log.Printf("tenant slot %d unavailable", slot)
+ http.Error(writer, "private chat runtime is starting", http.StatusBadGateway)
+ }
+ originalDirector := proxy.Director
+ proxy.Director = func(outbound *http.Request) {
+ originalDirector(outbound)
+ for _, header := range []string{
+ "Authorization", "Cookie", "X-Auth-Request-Access-Token",
+ "X-Auth-Request-Email", "X-Auth-Request-Groups", "X-Auth-Request-User",
+ "X-Forwarded-Access-Token", "X-Forwarded-Email", "X-Forwarded-Groups",
+ "X-Forwarded-Preferred-Username", "X-Forwarded-User",
+ } {
+ outbound.Header.Del(header)
+ }
+ outbound.Header.Del("Accept-Encoding")
+ }
+ proxy.ServeHTTP(writer, request)
+}
+
+func main() {
+ slots, err := strconv.Atoi(os.Getenv("TENANT_SLOTS"))
+ if err != nil || slots < 1 {
+ log.Fatal("TENANT_SLOTS must be a positive integer")
+ }
+ statePath := os.Getenv("TENANT_STATE_PATH")
+ if statePath == "" {
+ statePath = "/state/tenants.json"
+ }
+ router, err := newTenantRouter(statePath, slots, func(slot int) string {
+ return fmt.Sprintf("http://hermes-chat-tenant-%d.hermes-chat-tenant.hermes.svc.cluster.local:8787", slot)
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+ router.backendAPIURL = func(slot int) string {
+ return fmt.Sprintf("http://hermes-chat-tenant-%d.hermes-chat-tenant.hermes.svc.cluster.local:8642", slot)
+ }
+ telegramConfig, err := readTelegramConfig(os.Getenv("TELEGRAM_CONFIG_PATH"))
+ if err != nil {
+ log.Printf("Telegram is disabled: configuration is unavailable")
+ } else if telegramConfig.BotToken != "" && telegramConfig.RelayKey != "" {
+ router.telegram = newTelegramBot(telegramConfig, router)
+ go router.telegram.run()
+ log.Printf("Telegram transport enabled")
+ } else {
+ log.Printf("Telegram is prepared but disabled until bot_token is set in Vault")
+ }
+ server := &http.Server{
+ Addr: ":8080",
+ Handler: router,
+ ReadHeaderTimeout: 10 * time.Second,
+ }
+ log.Printf("Hermes chat tenant router ready with %d isolated slots", slots)
+ log.Fatal(server.ListenAndServe())
+}
diff --git a/services/hermes/router/main_test.go b/services/hermes/router/main_test.go
new file mode 100644
index 000000000..5c13ed0e9
--- /dev/null
+++ b/services/hermes/router/main_test.go
@@ -0,0 +1,182 @@
+package main
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestSubjectsReceiveStableIsolatedSlots(t *testing.T) {
+ statePath := filepath.Join(t.TempDir(), "tenants.json")
+ router, err := newTenantRouter(statePath, 2, func(slot int) string { return "" })
+ if err != nil {
+ t.Fatal(err)
+ }
+ first, err := router.slotFor("keycloak-subject-a")
+ if err != nil {
+ t.Fatal(err)
+ }
+ again, _ := router.slotFor("keycloak-subject-a")
+ second, err := router.slotFor("keycloak-subject-b")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first != again || first == second {
+ t.Fatalf("unexpected slots: first=%d again=%d second=%d", first, again, second)
+ }
+ if _, err := router.slotFor("keycloak-subject-c"); err == nil {
+ t.Fatal("expected the fixed private tenant pool to report capacity")
+ }
+ reloaded, err := newTenantRouter(statePath, 2, func(slot int) string { return "" })
+ if err != nil {
+ t.Fatal(err)
+ }
+ persisted, _ := reloaded.slotFor("keycloak-subject-a")
+ if persisted != first {
+ t.Fatalf("assignment changed after reload: %d != %d", persisted, first)
+ }
+}
+
+func TestRouterBlocksAdministrationAndRequiresIdentity(t *testing.T) {
+ router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "http://127.0.0.1" })
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, test := range []struct {
+ method string
+ path string
+ user string
+ want int
+ }{
+ {http.MethodGet, "/", "", http.StatusUnauthorized},
+ {http.MethodPost, "/api/providers", "subject", http.StatusForbidden},
+ {http.MethodPost, "/api/commands/exec", "subject", http.StatusForbidden},
+ {http.MethodGet, "/api/dashboard/config", "subject", http.StatusForbidden},
+ {http.MethodGet, "/api/env", "subject", http.StatusForbidden},
+ } {
+ request := httptest.NewRequest(test.method, test.path, nil)
+ request.Header.Set("X-Forwarded-User", test.user)
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+ if response.Code != test.want {
+ t.Fatalf("%s %s: got %d, want %d", test.method, test.path, response.Code, test.want)
+ }
+ }
+}
+
+func TestRouterProxiesWebUIAndAddsTelegramShortcut(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ if request.Header.Get("X-Forwarded-User") != "" || request.Header.Get("Cookie") != "" {
+ t.Fatal("identity or session cookie leaked to tenant backend")
+ }
+ writer.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = io.WriteString(writer, "Hermes WebUI")
+ }))
+ defer backend.Close()
+ router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return backend.URL })
+ if err != nil {
+ t.Fatal(err)
+ }
+ request := httptest.NewRequest(http.MethodGet, "/", nil)
+ request.Header.Set("X-Forwarded-User", "subject")
+ request.Header.Set("Cookie", "oauth-cookie")
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+ if response.Code != http.StatusOK {
+ t.Fatalf("got status %d", response.Code)
+ }
+ if !strings.Contains(response.Body.String(), "hermes-chat-bridge.js") || !strings.Contains(response.Body.String(), "hermes-chat-bridge.css") {
+ t.Fatal("Telegram shortcut assets were not injected")
+ }
+}
+
+func TestWebUIModelAndReasoningOverridesAreProxied(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ if request.Method != http.MethodPost {
+ t.Fatalf("got method %s", request.Method)
+ }
+ writer.WriteHeader(http.StatusNoContent)
+ }))
+ defer backend.Close()
+ router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return backend.URL })
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, path := range []string{"/api/model/set", "/api/reasoning"} {
+ request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
+ request.Header.Set("X-Forwarded-User", "subject")
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+ if response.Code != http.StatusNoContent {
+ t.Fatalf("POST %s: got %d", path, response.Code)
+ }
+ }
+}
+
+func TestTelegramLinkUsesHashedStateAndExpires(t *testing.T) {
+ statePath := filepath.Join(t.TempDir(), "state.json")
+ router, err := newTenantRouter(statePath, 1, func(slot int) string { return "" })
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)
+ router.now = func() time.Time { return now }
+ code, expires, err := router.createLink("raw-keycloak-subject")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if expires.Sub(now) != 10*time.Minute {
+ t.Fatalf("unexpected expiry: %s", expires)
+ }
+ if _, err := router.consumeLink("123456789", code); err != nil {
+ t.Fatal(err)
+ }
+ if _, linked := router.telegramSlot("123456789"); !linked {
+ t.Fatal("Telegram identity was not linked")
+ }
+ content, err := os.ReadFile(statePath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(content), "raw-keycloak-subject") || strings.Contains(string(content), "123456789") || strings.Contains(string(content), code) {
+ t.Fatal("raw identity or one-time code was persisted")
+ }
+ secondCode, _, err := router.createLink("raw-keycloak-subject")
+ if err != nil {
+ t.Fatal(err)
+ }
+ router.now = func() time.Time { return now.Add(11 * time.Minute) }
+ if _, err := router.consumeLink("987654321", secondCode); err == nil {
+ t.Fatal("expected expired link code to be rejected")
+ }
+}
+
+func TestTelegramWebActionsRequireExplicitSameOriginHeader(t *testing.T) {
+ router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "" })
+ if err != nil {
+ t.Fatal(err)
+ }
+ router.telegram = &telegramBot{}
+ request := httptest.NewRequest(http.MethodPost, "/api/telegram/link", strings.NewReader(`{}`))
+ request.Header.Set("X-Forwarded-User", "subject")
+ request.Header.Set("Content-Type", "application/json")
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+ if response.Code != http.StatusForbidden {
+ t.Fatalf("got %d", response.Code)
+ }
+ request = httptest.NewRequest(http.MethodPost, "/api/telegram/link", strings.NewReader(`{}`))
+ request.Header.Set("X-Forwarded-User", "subject")
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("X-Hermes-Action", "telegram-link")
+ response = httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+ if response.Code != http.StatusOK {
+ t.Fatalf("got %d: %s", response.Code, response.Body.String())
+ }
+}
diff --git a/services/hermes/router/telegram.go b/services/hermes/router/telegram.go
new file mode 100644
index 000000000..e58fcde6f
--- /dev/null
+++ b/services/hermes/router/telegram.go
@@ -0,0 +1,392 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+ "unicode/utf8"
+)
+
+type telegramConfig struct {
+ BotToken string
+ RelayKey string
+}
+
+type telegramBot struct {
+ config telegramConfig
+ router *tenantRouter
+ apiBase string
+ client *http.Client
+ agentClient *http.Client
+ mu sync.RWMutex
+ botUsername string
+ workLimit chan struct{}
+ slotLocks map[int]*sync.Mutex
+ slotLocksMux sync.Mutex
+}
+
+type telegramUpdate struct {
+ UpdateID int64 `json:"update_id"`
+ Message *telegramMessage `json:"message"`
+}
+
+type telegramMessage struct {
+ MessageID int64 `json:"message_id"`
+ From *telegramUser `json:"from"`
+ Chat telegramChat `json:"chat"`
+ Text string `json:"text"`
+}
+
+type telegramUser struct {
+ ID int64 `json:"id"`
+}
+
+type telegramChat struct {
+ ID int64 `json:"id"`
+ Type string `json:"type"`
+}
+
+func readTelegramConfig(path string) (telegramConfig, error) {
+ if path == "" {
+ path = "/vault/secrets/telegram-config"
+ }
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return telegramConfig{}, err
+ }
+ config := telegramConfig{}
+ for _, line := range strings.Split(string(content), "\n") {
+ key, value, found := strings.Cut(strings.TrimSpace(line), "=")
+ if !found {
+ continue
+ }
+ switch strings.TrimSpace(key) {
+ case "bot_token":
+ config.BotToken = strings.TrimSpace(value)
+ case "relay_key":
+ config.RelayKey = strings.TrimSpace(value)
+ }
+ }
+ return config, nil
+}
+
+func newTelegramBot(config telegramConfig, router *tenantRouter) *telegramBot {
+ return &telegramBot{
+ config: config,
+ router: router,
+ apiBase: "https://api.telegram.org/bot" + config.BotToken,
+ client: &http.Client{Timeout: 70 * time.Second},
+ agentClient: &http.Client{Timeout: 15 * time.Minute},
+ workLimit: make(chan struct{}, 4),
+ slotLocks: map[int]*sync.Mutex{},
+ }
+}
+
+func (bot *telegramBot) username() string {
+ bot.mu.RLock()
+ defer bot.mu.RUnlock()
+ return bot.botUsername
+}
+
+func (bot *telegramBot) setUsername(username string) {
+ bot.mu.Lock()
+ defer bot.mu.Unlock()
+ bot.botUsername = strings.TrimPrefix(strings.TrimSpace(username), "@")
+}
+
+func (bot *telegramBot) slotLock(slot int) *sync.Mutex {
+ bot.slotLocksMux.Lock()
+ defer bot.slotLocksMux.Unlock()
+ if bot.slotLocks[slot] == nil {
+ bot.slotLocks[slot] = &sync.Mutex{}
+ }
+ return bot.slotLocks[slot]
+}
+
+func (bot *telegramBot) call(ctx context.Context, method string, values url.Values, result any) error {
+ request, err := http.NewRequestWithContext(
+ ctx,
+ http.MethodPost,
+ bot.apiBase+"/"+method,
+ strings.NewReader(values.Encode()),
+ )
+ if err != nil {
+ return errors.New("create Telegram request")
+ }
+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ response, err := bot.client.Do(request)
+ if err != nil {
+ return errors.New("Telegram API unavailable")
+ }
+ defer response.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(response.Body, 2<<20))
+ if err != nil {
+ return errors.New("read Telegram response")
+ }
+ var envelope struct {
+ OK bool `json:"ok"`
+ Result json.RawMessage `json:"result"`
+ }
+ if response.StatusCode != http.StatusOK || json.Unmarshal(body, &envelope) != nil || !envelope.OK {
+ return fmt.Errorf("Telegram API returned status %d", response.StatusCode)
+ }
+ if result != nil && len(envelope.Result) > 0 {
+ if err := json.Unmarshal(envelope.Result, result); err != nil {
+ return errors.New("decode Telegram response")
+ }
+ }
+ return nil
+}
+
+func (bot *telegramBot) run() {
+ for {
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ var me struct {
+ Username string `json:"username"`
+ }
+ err := bot.call(ctx, "getMe", url.Values{}, &me)
+ cancel()
+ if err == nil {
+ bot.setUsername(me.Username)
+ break
+ }
+ time.Sleep(10 * time.Second)
+ }
+ for {
+ offset := bot.router.telegramOffset()
+ ctx, cancel := context.WithTimeout(context.Background(), 65*time.Second)
+ values := url.Values{
+ "offset": {strconv.FormatInt(offset, 10)},
+ "timeout": {"50"},
+ "allowed_updates": {`["message"]`},
+ }
+ var updates []telegramUpdate
+ err := bot.call(ctx, "getUpdates", values, &updates)
+ cancel()
+ if err != nil {
+ time.Sleep(3 * time.Second)
+ continue
+ }
+ for _, update := range updates {
+ _ = bot.router.setTelegramOffset(update.UpdateID + 1)
+ bot.workLimit <- struct{}{}
+ go func(update telegramUpdate) {
+ defer func() { <-bot.workLimit }()
+ bot.handleUpdate(update)
+ }(update)
+ }
+ }
+}
+
+func commandParts(text string) (string, []string) {
+ fields := strings.Fields(strings.TrimSpace(text))
+ if len(fields) == 0 || !strings.HasPrefix(fields[0], "/") {
+ return "", nil
+ }
+ command := strings.TrimPrefix(strings.ToLower(fields[0]), "/")
+ command, _, _ = strings.Cut(command, "@")
+ return command, fields[1:]
+}
+
+func (bot *telegramBot) handleUpdate(update telegramUpdate) {
+ message := update.Message
+ if message == nil || message.From == nil || message.Chat.Type != "private" || message.Chat.ID != message.From.ID {
+ return
+ }
+ userID := strconv.FormatInt(message.From.ID, 10)
+ command, args := commandParts(message.Text)
+ if command == "start" || command == "link" {
+ if len(args) == 0 {
+ _ = bot.sendText(message.Chat.ID, "Sign in to chat.hermes.bstein.dev, open Telegram, and create a one-time link code.")
+ return
+ }
+ if _, err := bot.router.consumeLink(userID, args[0]); err != nil {
+ _ = bot.sendText(message.Chat.ID, "That link code is invalid or expired. Create a new one in Hermes WebUI.")
+ return
+ }
+ _ = bot.sendText(message.Chat.ID, "Telegram is linked to your private Hermes account. Send a message whenever you are ready.")
+ return
+ }
+ if command == "unlink" {
+ if err := bot.router.unlinkTelegramUser(userID); err != nil {
+ _ = bot.sendText(message.Chat.ID, "I could not unlink Telegram right now. Try again shortly.")
+ return
+ }
+ _ = bot.sendText(message.Chat.ID, "Telegram has been unlinked from Hermes.")
+ return
+ }
+ if command == "help" {
+ _ = bot.sendText(message.Chat.ID, "Send any text to chat with Hermes. Use /unlink to disconnect this Telegram account. Model and intensity controls are available in Hermes WebUI.")
+ return
+ }
+ slot, linked := bot.router.telegramSlot(userID)
+ if !linked {
+ _ = bot.sendText(message.Chat.ID, "This Telegram account is not linked. Sign in to chat.hermes.bstein.dev and open Telegram to connect it.")
+ return
+ }
+ text := strings.TrimSpace(message.Text)
+ if text == "" {
+ _ = bot.sendText(message.Chat.ID, "Text messages are supported now; attachment support will be added separately.")
+ return
+ }
+ if utf8.RuneCountInString(text) > 12000 {
+ _ = bot.sendText(message.Chat.ID, "That message is too long. Please split it into smaller parts.")
+ return
+ }
+ lock := bot.slotLock(slot)
+ lock.Lock()
+ defer lock.Unlock()
+ _ = bot.sendAction(message.Chat.ID, "typing")
+ reply, err := bot.askTenant(slot, text, update.UpdateID)
+ if err != nil {
+ _ = bot.sendText(message.Chat.ID, "Hermes could not answer right now. Please try again shortly.")
+ return
+ }
+ _ = bot.sendText(message.Chat.ID, reply)
+}
+
+func (bot *telegramBot) askTenant(slot int, text string, updateID int64) (string, error) {
+ if bot.router.backendAPIURL == nil {
+ return "", errors.New("tenant API unavailable")
+ }
+ payload, _ := json.Marshal(map[string]any{
+ "input": text,
+ "conversation": "telegram",
+ "store": true,
+ })
+ ctx, cancel := context.WithTimeout(context.Background(), 14*time.Minute)
+ defer cancel()
+ request, err := http.NewRequestWithContext(
+ ctx,
+ http.MethodPost,
+ bot.router.backendAPIURL(slot)+"/v1/responses",
+ bytes.NewReader(payload),
+ )
+ if err != nil {
+ return "", err
+ }
+ request.Header.Set("Authorization", "Bearer "+bot.config.RelayKey)
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("X-Hermes-Session-Key", "telegram")
+ request.Header.Set("Idempotency-Key", "telegram-"+strconv.FormatInt(updateID, 10))
+ response, err := bot.agentClient.Do(request)
+ if err != nil {
+ return "", errors.New("tenant request failed")
+ }
+ defer response.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(response.Body, 8<<20))
+ if err != nil || response.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("tenant returned status %d", response.StatusCode)
+ }
+ var parsed struct {
+ Output []struct {
+ Type string `json:"type"`
+ Role string `json:"role"`
+ Content []struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ } `json:"content"`
+ } `json:"output"`
+ }
+ if err := json.Unmarshal(body, &parsed); err != nil {
+ return "", errors.New("decode tenant response")
+ }
+ var parts []string
+ for _, item := range parsed.Output {
+ if item.Type != "message" || item.Role != "assistant" {
+ continue
+ }
+ for _, content := range item.Content {
+ if (content.Type == "output_text" || content.Type == "text") && strings.TrimSpace(content.Text) != "" {
+ parts = append(parts, strings.TrimSpace(content.Text))
+ }
+ }
+ }
+ answer := strings.Join(parts, "\n\n")
+ if answer == "" {
+ return "", errors.New("tenant returned no assistant text")
+ }
+ return answer, nil
+}
+
+func (bot *telegramBot) sendAction(chatID int64, action string) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ return bot.call(ctx, "sendChatAction", url.Values{
+ "chat_id": {strconv.FormatInt(chatID, 10)},
+ "action": {action},
+ }, nil)
+}
+
+func splitTelegramText(text string) []string {
+ runes := []rune(strings.TrimSpace(text))
+ if len(runes) == 0 {
+ return []string{"Hermes completed the request without a text response."}
+ }
+ const limit = 3900
+ var chunks []string
+ for len(runes) > limit {
+ cut := limit
+ for index := limit; index > limit-500; index-- {
+ if runes[index-1] == '\n' || runes[index-1] == ' ' {
+ cut = index
+ break
+ }
+ }
+ chunks = append(chunks, strings.TrimSpace(string(runes[:cut])))
+ runes = runes[cut:]
+ }
+ if tail := strings.TrimSpace(string(runes)); tail != "" {
+ chunks = append(chunks, tail)
+ }
+ return chunks
+}
+
+func (bot *telegramBot) sendText(chatID int64, text string) error {
+ for _, chunk := range splitTelegramText(text) {
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ err := bot.call(ctx, "sendMessage", url.Values{
+ "chat_id": {strconv.FormatInt(chatID, 10)},
+ "text": {chunk},
+ "disable_web_page_preview": {"true"},
+ }, nil)
+ cancel()
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (router *tenantRouter) telegramOffset() int64 {
+ router.mu.Lock()
+ defer router.mu.Unlock()
+ return router.state.TelegramOffset
+}
+
+func (router *tenantRouter) setTelegramOffset(offset int64) error {
+ router.mu.Lock()
+ defer router.mu.Unlock()
+ if offset <= router.state.TelegramOffset {
+ return nil
+ }
+ router.state.TelegramOffset = offset
+ return router.saveLocked()
+}
+
+func (router *tenantRouter) unlinkTelegramUser(userID string) error {
+ router.mu.Lock()
+ defer router.mu.Unlock()
+ delete(router.state.Telegram, router.identityHash("telegram", userID))
+ return router.saveLocked()
+}
diff --git a/services/hermes/router/telegram_test.go b/services/hermes/router/telegram_test.go
new file mode 100644
index 000000000..6762ab33d
--- /dev/null
+++ b/services/hermes/router/telegram_test.go
@@ -0,0 +1,74 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestReadTelegramConfig(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "telegram-config")
+ if err := os.WriteFile(path, []byte("bot_token=123:abc\nrelay_key=relay-secret\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ config, err := readTelegramConfig(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if config.BotToken != "123:abc" || config.RelayKey != "relay-secret" {
+ t.Fatalf("unexpected config: %#v", config)
+ }
+}
+
+func TestAskTenantUsesAuthenticatedNamedConversation(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ if request.URL.Path != "/v1/responses" {
+ t.Fatalf("unexpected path %s", request.URL.Path)
+ }
+ if request.Header.Get("Authorization") != "Bearer relay-secret" {
+ t.Fatal("relay authentication was not set")
+ }
+ if request.Header.Get("X-Hermes-Session-Key") != "telegram" {
+ t.Fatal("Telegram session scope was not set")
+ }
+ var payload map[string]any
+ if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
+ t.Fatal(err)
+ }
+ if payload["conversation"] != "telegram" || payload["input"] != "hello" {
+ t.Fatalf("unexpected payload: %#v", payload)
+ }
+ writer.Header().Set("Content-Type", "application/json")
+ _, _ = writer.Write([]byte(`{"output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hello from Hermes"}]}]}`))
+ }))
+ defer server.Close()
+ router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "" })
+ if err != nil {
+ t.Fatal(err)
+ }
+ router.backendAPIURL = func(slot int) string { return server.URL }
+ bot := newTelegramBot(telegramConfig{RelayKey: "relay-secret"}, router)
+ answer, err := bot.askTenant(0, "hello", 42)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if answer != "Hello from Hermes" {
+ t.Fatalf("unexpected answer %q", answer)
+ }
+}
+
+func TestSplitTelegramTextStaysUnderTelegramLimit(t *testing.T) {
+ chunks := splitTelegramText(strings.Repeat("word ", 2000))
+ if len(chunks) < 2 {
+ t.Fatal("expected a long response to be split")
+ }
+ for _, chunk := range chunks {
+ if len([]rune(chunk)) > 3900 {
+ t.Fatalf("chunk exceeds limit: %d", len([]rune(chunk)))
+ }
+ }
+}
diff --git a/services/hermes/router/web.go b/services/hermes/router/web.go
new file mode 100644
index 000000000..ea571ca7f
--- /dev/null
+++ b/services/hermes/router/web.go
@@ -0,0 +1,232 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const telegramPage = `
+
+
+
+
+ Hermes on Telegram
+
+
+
+
+ ← Back to Hermes
+ Hermes on Telegram
+ Link this Keycloak account to a private Telegram chat. Messages will use the same isolated Hermes tenant as the WebUI.
+ Checking Telegram…
+
+
+
+
+
+ Codes expire after 10 minutes. Only direct messages are accepted; group messages are ignored.
+
+
+
+`
+
+const bridgeCSS = `
+#hermes-telegram-shortcut{position:fixed;right:18px;bottom:18px;z-index:9999;padding:10px 14px;border-radius:999px;background:#229ed9;color:#fff;text-decoration:none;font:600 14px system-ui,sans-serif;box-shadow:0 5px 20px #0005}
+.hermes-link-page{margin:0;min-height:100vh;display:grid;place-items:center;background:#0f172a;color:#e2e8f0;font:16px/1.5 system-ui,sans-serif}
+.hermes-link-card{width:min(620px,calc(100% - 40px));box-sizing:border-box;padding:32px;border:1px solid #334155;border-radius:18px;background:#111827;box-shadow:0 20px 60px #0006}
+.hermes-link-card h1{margin:.6rem 0}.hermes-back{color:#7dd3fc}.hermes-link-actions{display:flex;gap:12px;flex-wrap:wrap;margin:24px 0}
+.hermes-link-card button{border:0;border-radius:10px;padding:11px 16px;background:#229ed9;color:#fff;font-weight:700;cursor:pointer}.hermes-link-card button.secondary{background:#334155}
+#telegram-result{padding:16px;border-radius:10px;background:#1e293b;overflow-wrap:anywhere}#telegram-result a{color:#7dd3fc}.hermes-fine-print{color:#94a3b8;font-size:13px}
+`
+
+const bridgeJS = `(() => {
+ const page = document.querySelector('[data-telegram-page]');
+ if (!page) {
+ if (!document.getElementById('hermes-telegram-shortcut')) {
+ const link = document.createElement('a');
+ link.id = 'hermes-telegram-shortcut';
+ link.href = '/telegram';
+ link.textContent = 'Telegram';
+ link.setAttribute('aria-label', 'Connect Hermes to Telegram');
+ document.body.appendChild(link);
+ }
+ return;
+ }
+ const status = document.getElementById('telegram-status');
+ const result = document.getElementById('telegram-result');
+ const linkButton = document.getElementById('telegram-link');
+ const unlinkButton = document.getElementById('telegram-unlink');
+ const action = async (path) => {
+ const response = await fetch(path, {method:'POST',headers:{'Content-Type':'application/json','X-Hermes-Action':'telegram-link'},body:'{}'});
+ const payload = await response.json();
+ if (!response.ok) throw new Error(payload.error || 'Request failed');
+ return payload;
+ };
+ const refresh = async () => {
+ try {
+ const response = await fetch('/api/telegram/status', {cache:'no-store'});
+ const payload = await response.json();
+ if (!payload.configured) {
+ status.textContent = 'Telegram is prepared, but the bot token has not been added by the operator yet.';
+ linkButton.disabled = true;
+ unlinkButton.hidden = true;
+ return;
+ }
+ status.textContent = payload.linked ? 'Telegram is linked to this private account.' : 'Telegram is ready to link.';
+ unlinkButton.hidden = !payload.linked;
+ } catch (_) { status.textContent = 'Telegram status is temporarily unavailable.'; }
+ };
+ linkButton.addEventListener('click', async () => {
+ try {
+ const payload = await action('/api/telegram/link');
+ result.hidden = false;
+ result.replaceChildren();
+ const text = document.createElement('p');
+ text.textContent = 'Send /link ' + payload.code + ' to the Hermes bot. This code expires at ' + new Date(payload.expires_at).toLocaleTimeString() + '.';
+ result.appendChild(text);
+ if (payload.deep_link) {
+ const anchor = document.createElement('a');
+ anchor.href = payload.deep_link;
+ anchor.rel = 'noopener noreferrer';
+ anchor.textContent = 'Open Telegram and link now';
+ result.appendChild(anchor);
+ }
+ } catch (error) { status.textContent = error.message; }
+ });
+ unlinkButton.addEventListener('click', async () => {
+ try { await action('/api/telegram/unlink'); result.hidden = true; await refresh(); }
+ catch (error) { status.textContent = error.message; }
+ });
+ refresh();
+})();`
+
+func writeJSON(writer http.ResponseWriter, status int, value any) {
+ writer.Header().Set("Content-Type", "application/json")
+ writer.Header().Set("Cache-Control", "no-store")
+ writer.WriteHeader(status)
+ _ = json.NewEncoder(writer).Encode(value)
+}
+
+func validTelegramAction(request *http.Request) bool {
+ return request.Header.Get("X-Hermes-Action") == "telegram-link" &&
+ strings.HasPrefix(request.Header.Get("Content-Type"), "application/json")
+}
+
+func (router *tenantRouter) serveTelegramWeb(writer http.ResponseWriter, request *http.Request, subject string) bool {
+ switch request.URL.Path {
+ case "/hermes-chat-bridge.css":
+ if request.Method != http.MethodGet {
+ http.Error(writer, "method not allowed", http.StatusMethodNotAllowed)
+ return true
+ }
+ writer.Header().Set("Content-Type", "text/css; charset=utf-8")
+ writer.Header().Set("Cache-Control", "public, max-age=3600")
+ _, _ = io.WriteString(writer, bridgeCSS)
+ return true
+ case "/hermes-chat-bridge.js":
+ if request.Method != http.MethodGet {
+ http.Error(writer, "method not allowed", http.StatusMethodNotAllowed)
+ return true
+ }
+ writer.Header().Set("Content-Type", "application/javascript; charset=utf-8")
+ writer.Header().Set("Cache-Control", "public, max-age=3600")
+ _, _ = io.WriteString(writer, bridgeJS)
+ return true
+ case "/telegram":
+ if request.Method != http.MethodGet {
+ http.Error(writer, "method not allowed", http.StatusMethodNotAllowed)
+ return true
+ }
+ writer.Header().Set("Content-Type", "text/html; charset=utf-8")
+ writer.Header().Set("Cache-Control", "no-store")
+ writer.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'")
+ _, _ = io.WriteString(writer, telegramPage)
+ return true
+ case "/api/telegram/status":
+ if request.Method != http.MethodGet {
+ writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
+ return true
+ }
+ linked, err := router.telegramLinked(subject)
+ if err != nil {
+ writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
+ return true
+ }
+ username := ""
+ if router.telegram != nil {
+ username = router.telegram.username()
+ }
+ writeJSON(writer, http.StatusOK, map[string]any{
+ "configured": router.telegram != nil,
+ "linked": linked,
+ "bot_username": username,
+ })
+ return true
+ case "/api/telegram/link":
+ if request.Method != http.MethodPost || !validTelegramAction(request) {
+ writeJSON(writer, http.StatusForbidden, map[string]string{"error": "same-origin action required"})
+ return true
+ }
+ if router.telegram == nil {
+ writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": "Telegram bot token is not configured"})
+ return true
+ }
+ code, expires, err := router.createLink(subject)
+ if err != nil {
+ writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
+ return true
+ }
+ username := router.telegram.username()
+ deepLink := ""
+ if username != "" {
+ deepLink = fmt.Sprintf("https://t.me/%s?start=%s", url.PathEscape(username), url.QueryEscape(code))
+ }
+ writeJSON(writer, http.StatusOK, map[string]any{
+ "code": code,
+ "expires_at": expires.Format(time.RFC3339),
+ "deep_link": deepLink,
+ })
+ return true
+ case "/api/telegram/unlink":
+ if request.Method != http.MethodPost || !validTelegramAction(request) {
+ writeJSON(writer, http.StatusForbidden, map[string]string{"error": "same-origin action required"})
+ return true
+ }
+ if err := router.unlinkTelegram(subject); err != nil {
+ writeJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
+ return true
+ }
+ writeJSON(writer, http.StatusOK, map[string]bool{"unlinked": true})
+ return true
+ default:
+ return false
+ }
+}
+
+func injectChatBridge(response *http.Response) error {
+ if !strings.Contains(response.Header.Get("Content-Type"), "text/html") {
+ return nil
+ }
+ body, err := io.ReadAll(response.Body)
+ if err != nil {
+ return err
+ }
+ _ = response.Body.Close()
+ content := string(body)
+ if !strings.Contains(content, "hermes-chat-bridge.js") {
+ content = strings.Replace(content, "", ``, 1)
+ content = strings.Replace(content, "