hermes: isolate runtime access and expose activity

This commit is contained in:
jenkins 2026-08-15 17:58:47 -03:00
parent d29a5737fe
commit 89b9f1a4c7
45 changed files with 1717 additions and 380 deletions

View File

@ -30,6 +30,10 @@ spec:
kind: Deployment
name: hermes-agent
namespace: hermes
- apiVersion: apps/v1
kind: DaemonSet
name: hermes-node-ssh-access
namespace: hermes
- apiVersion: apps/v1
kind: Deployment
name: hermes

View File

@ -0,0 +1,6 @@
**
!dockerfiles/hermes-python-sandbox-tool.py
!dockerfiles/hermes-public-extract/
!dockerfiles/hermes-public-extract/**
!dockerfiles/hermes-session-activity-panel.tsx
!dockerfiles/hermes-session-migrate.py

View File

@ -104,8 +104,9 @@ function ActivityMessage({
/**
* Show every persisted message and tool event without relying on xterm's
* canvas. API workers default to this view; the normal terminal remains one
* click away and continues running underneath it.
* canvas. Resumed sessions default to this view so an unknown or delayed
* backend source classification cannot hide activity; the normal terminal
* remains one click away and continues running underneath it.
*/
export function SessionActivityPanel({
sessionId,
@ -129,7 +130,7 @@ export function SessionActivityPanel({
api.getSessionMessages(sessionId, profile ?? ""),
]);
setDetail(nextDetail);
setMessages(response.messages);
setMessages(Array.isArray(response.messages) ? response.messages : []);
setError(null);
setPollTime(Date.now());
if (stickToBottom.current) {
@ -169,9 +170,6 @@ export function SessionActivityPanel({
? "Working"
: "Run finished";
// Ordinary interactive TUI sessions keep their established terminal UI.
if (detail && detail.source !== "api_server") return null;
if (!expanded) {
return (
<Button
@ -235,6 +233,11 @@ export function SessionActivityPanel({
<div
ref={scrollRef}
className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3"
role="log"
aria-live="polite"
aria-relevant="additions text"
aria-busy={running && messages.length === 0}
aria-label="Chronological Hermes worker activity"
tabIndex={0}
onScroll={(event) => {
const node = event.currentTarget;

View File

@ -3,6 +3,6 @@
"helmrelease_host_hints": 23,
"http_endpoints": 61,
"services": 91,
"workloads": 124
"workloads": 125
}
}

View File

@ -892,6 +892,19 @@
"wger/server@sha256:710588b78af4e0aa0b4d8a8061e4563e16eae80eeaccfe7f9e0d9cbdd7f0cbc5"
]
},
{
"kind": "DaemonSet",
"namespace": "hermes",
"name": "hermes-node-ssh-access",
"labels": {
"app": "hermes-node-ssh-access"
},
"serviceAccountName": "hermes-node-ssh-access",
"nodeSelector": {},
"images": [
"busybox:1.37"
]
},
{
"kind": "Deployment",
"namespace": "hermes",
@ -902,7 +915,7 @@
"serviceAccountName": "hermes-triage",
"nodeSelector": {},
"images": [
"registry.bstein.dev/bstein/hermes-agent@sha256:3154e1df5f6941583f4a369fcc9581bbf9cb9d502676e91b557e90cc576923e1",
"registry.bstein.dev/bstein/hermes-agent@sha256:cce1f65dc7d30fdce4b1748cc7d3434b9761ae9946a8c8be6049204c503d60a8",
"registry.bstein.dev/bstein/hermes-webui@sha256:9c2fe8341c7b650e08d10acead3151b19e2af737863268bafb39b3d9517575b1"
]
},
@ -917,7 +930,7 @@
"nodeSelector": {},
"images": [
"quay.io/oauth2-proxy/oauth2-proxy:v7.15.3@sha256:10a1165743a192e1940b4708fb9647027185ce11a681a1c5519b442ff7f1f561",
"registry.bstein.dev/bstein/hermes-agent@sha256:3154e1df5f6941583f4a369fcc9581bbf9cb9d502676e91b557e90cc576923e1"
"registry.bstein.dev/bstein/hermes-agent@sha256:cce1f65dc7d30fdce4b1748cc7d3434b9761ae9946a8c8be6049204c503d60a8"
]
},
{
@ -1164,7 +1177,7 @@
"serviceAccountName": "hermes-chat",
"nodeSelector": {},
"images": [
"registry.bstein.dev/bstein/hermes-agent@sha256:3154e1df5f6941583f4a369fcc9581bbf9cb9d502676e91b557e90cc576923e1",
"registry.bstein.dev/bstein/hermes-agent@sha256:cce1f65dc7d30fdce4b1748cc7d3434b9761ae9946a8c8be6049204c503d60a8",
"registry.bstein.dev/bstein/hermes-webui@sha256:9c2fe8341c7b650e08d10acead3151b19e2af737863268bafb39b3d9517575b1"
]
},
@ -1515,7 +1528,7 @@
"kubernetes.io/os": "linux"
},
"images": [
"registry.bstein.dev/bstein/metis-sentinel:0.1.0-292-amd64"
"registry.bstein.dev/bstein/metis-sentinel:0.1.0-293-amd64"
]
},
{
@ -1531,7 +1544,7 @@
"kubernetes.io/os": "linux"
},
"images": [
"registry.bstein.dev/bstein/metis-sentinel:0.1.0-291-arm64"
"registry.bstein.dev/bstein/metis-sentinel:0.1.0-293-arm64"
]
},
{
@ -1648,7 +1661,7 @@
"serviceAccountName": "metis",
"nodeSelector": {},
"images": [
"registry.bstein.dev/bstein/metis:0.1.0-291-arm64"
"registry.bstein.dev/bstein/metis:0.1.0-293-arm64"
]
},
{

View File

@ -596,6 +596,15 @@ workloads:
images:
- nginx:1.27.5-alpine@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10
- wger/server@sha256:710588b78af4e0aa0b4d8a8061e4563e16eae80eeaccfe7f9e0d9cbdd7f0cbc5
- kind: DaemonSet
namespace: hermes
name: hermes-node-ssh-access
labels:
app: hermes-node-ssh-access
serviceAccountName: hermes-node-ssh-access
nodeSelector: {}
images:
- busybox:1.37
- kind: Deployment
namespace: hermes
name: hermes
@ -604,7 +613,7 @@ workloads:
serviceAccountName: hermes-triage
nodeSelector: {}
images:
- registry.bstein.dev/bstein/hermes-agent@sha256:3154e1df5f6941583f4a369fcc9581bbf9cb9d502676e91b557e90cc576923e1
- registry.bstein.dev/bstein/hermes-agent@sha256:cce1f65dc7d30fdce4b1748cc7d3434b9761ae9946a8c8be6049204c503d60a8
- registry.bstein.dev/bstein/hermes-webui@sha256:9c2fe8341c7b650e08d10acead3151b19e2af737863268bafb39b3d9517575b1
- kind: Deployment
namespace: hermes
@ -615,7 +624,7 @@ workloads:
nodeSelector: {}
images:
- quay.io/oauth2-proxy/oauth2-proxy:v7.15.3@sha256:10a1165743a192e1940b4708fb9647027185ce11a681a1c5519b442ff7f1f561
- registry.bstein.dev/bstein/hermes-agent@sha256:3154e1df5f6941583f4a369fcc9581bbf9cb9d502676e91b557e90cc576923e1
- registry.bstein.dev/bstein/hermes-agent@sha256:cce1f65dc7d30fdce4b1748cc7d3434b9761ae9946a8c8be6049204c503d60a8
- kind: Deployment
namespace: hermes
name: hermes-chat-router
@ -788,7 +797,7 @@ workloads:
serviceAccountName: hermes-chat
nodeSelector: {}
images:
- registry.bstein.dev/bstein/hermes-agent@sha256:3154e1df5f6941583f4a369fcc9581bbf9cb9d502676e91b557e90cc576923e1
- registry.bstein.dev/bstein/hermes-agent@sha256:cce1f65dc7d30fdce4b1748cc7d3434b9761ae9946a8c8be6049204c503d60a8
- registry.bstein.dev/bstein/hermes-webui@sha256:9c2fe8341c7b650e08d10acead3151b19e2af737863268bafb39b3d9517575b1
- kind: Deployment
namespace: jellyfin
@ -1028,7 +1037,7 @@ workloads:
kubernetes.io/arch: amd64
kubernetes.io/os: linux
images:
- registry.bstein.dev/bstein/metis-sentinel:0.1.0-292-amd64
- registry.bstein.dev/bstein/metis-sentinel:0.1.0-293-amd64
- kind: DaemonSet
namespace: maintenance
name: metis-sentinel-arm64
@ -1039,7 +1048,7 @@ workloads:
kubernetes.io/arch: arm64
kubernetes.io/os: linux
images:
- registry.bstein.dev/bstein/metis-sentinel:0.1.0-291-arm64
- registry.bstein.dev/bstein/metis-sentinel:0.1.0-293-arm64
- kind: DaemonSet
namespace: maintenance
name: node-image-sweeper
@ -1118,7 +1127,7 @@ workloads:
serviceAccountName: metis
nodeSelector: {}
images:
- registry.bstein.dev/bstein/metis:0.1.0-291-arm64
- registry.bstein.dev/bstein/metis:0.1.0-293-arm64
- kind: Deployment
namespace: maintenance
name: oauth2-proxy-metis

File diff suppressed because one or more lines are too long

View File

@ -135,11 +135,10 @@ alert fires instead.
## Credentials
- Ariadne → Hermes: `Authorization: Bearer` key shared via the
`hermes-api-server-key` Secret present in both `hermes` and `maintenance`
namespaces (Hermes's init container seeds it into the persistent `.env`).
NOTE: currently manually created (Vault migration pending — see plan
handoff); rotating = write new value to both Secrets, restart hermes
deployment and ariadne deployment.
- Ariadne → Hermes: `Authorization: Bearer` key from
`kv/atlas/hermes/triage-api`. Vault Agent injects it into both workloads at
runtime; it is not stored in either persistent Hermes state or a manually
maintained Kubernetes Secret. Rotate the Vault value and roll both tracked
deployments through Flux.
- Ariadne → Jenkins: existing `JENKINS_API_USER/TOKEN` from Vault
(`atlas/maintenance/ariadne-db`).

View File

@ -3,15 +3,26 @@ set -euo pipefail
# === CONFIG ===
STYX_USER="styx"
STYX_PASS="TempPass#123" # change at first login
STYX_PASS="${STYX_PASS:-}"
STYX_HOSTNAME="styx"
SSH_PUBKEY="" # e.g., 'ssh-ed25519 AAAA... your@host' (optional)
SSH_PUBKEY="${SSH_PUBKEY:-}"
# === helpers ===
require_root() {
if [[ $EUID -ne 0 ]]; then exec sudo -E "$0" "$@"; fi
}
require_runtime_access() {
[[ -n "$STYX_PASS" ]] || {
echo "STYX_PASS must be injected at runtime from Vault." >&2
exit 1
}
[[ -n "$SSH_PUBKEY" ]] || {
echo "SSH_PUBKEY must be injected at runtime from Vault." >&2
exit 1
}
}
ensure_binfmt_arm64() {
# If binfmt for arm64 isn't registered, register it via Docker (idempotent).
if [[ ! -e /proc/sys/fs/binfmt_misc/qemu-aarch64 ]]; then
@ -176,6 +187,7 @@ cleanup() {
main() {
require_root
require_runtime_access
ensure_binfmt_arm64
find_parts
trap 'echo "ERROR at line $LINENO" >&2; cleanup' ERR INT
@ -185,7 +197,7 @@ main() {
boot_tweaks
cleanup
echo "✅ Done. Move the NVMe to the Pi and boot."
echo " Login: user '${STYX_USER}' pass '${STYX_PASS}' (change with 'passwd')."
echo " Login: user '${STYX_USER}' (change the injected bootstrap password with 'passwd')."
echo " Quick checks on the Pi:"
echo " sudo i2cdetect -y 1"
echo " rpicam-still -n -o test.jpg # (if rpicam-apps installed)"

View File

@ -15,8 +15,8 @@ TGT_BOOT="/mnt/target-boot"
STYX_USER="styx"
STYX_HOSTNAME="titan-ag"
STYX_PASS="TempPass#123" # will be forced to change on first login via cloud-init
SSH_PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOb8oMX6u0z3sH/p/WBGlvPXXdbGETCKzWYwR/dd6fZb titan-bastion"
STYX_PASS="${STYX_PASS:-}"
SSH_PUBKEY="${SSH_PUBKEY:-}"
# Video / input prefs
DSI_FLAGS="video=DSI-1:800x480@60D video=HDMI-A-1:off video=HDMI-A-2:off"
@ -27,6 +27,11 @@ need(){ command -v "$1" >/dev/null || fatal "Missing tool: $1"; }
require_root(){ [[ $EUID -eq 0 ]] || exec sudo -E "$0" "$@"; }
require_runtime_access() {
[[ -n "$STYX_PASS" ]] || fatal "STYX_PASS must be injected at runtime from Vault."
[[ -n "$SSH_PUBKEY" ]] || fatal "SSH_PUBKEY must be injected at runtime from Vault."
}
part() {
local n="$1"
if [[ "$NVME" =~ [0-9]$ ]]; then
@ -544,6 +549,7 @@ cleanup(){
main(){
require_root
require_runtime_access
need losetup; need parted; need rsync
auto_detect_target_disk
echo "Target disk: $NVME"

View File

@ -3,6 +3,6 @@
"helmrelease_host_hints": 23,
"http_endpoints": 61,
"services": 91,
"workloads": 124
"workloads": 125
}
}

View File

@ -892,6 +892,19 @@
"wger/server@sha256:710588b78af4e0aa0b4d8a8061e4563e16eae80eeaccfe7f9e0d9cbdd7f0cbc5"
]
},
{
"kind": "DaemonSet",
"namespace": "hermes",
"name": "hermes-node-ssh-access",
"labels": {
"app": "hermes-node-ssh-access"
},
"serviceAccountName": "hermes-node-ssh-access",
"nodeSelector": {},
"images": [
"busybox:1.37"
]
},
{
"kind": "Deployment",
"namespace": "hermes",
@ -902,7 +915,7 @@
"serviceAccountName": "hermes-triage",
"nodeSelector": {},
"images": [
"registry.bstein.dev/bstein/hermes-agent@sha256:3154e1df5f6941583f4a369fcc9581bbf9cb9d502676e91b557e90cc576923e1",
"registry.bstein.dev/bstein/hermes-agent@sha256:cce1f65dc7d30fdce4b1748cc7d3434b9761ae9946a8c8be6049204c503d60a8",
"registry.bstein.dev/bstein/hermes-webui@sha256:9c2fe8341c7b650e08d10acead3151b19e2af737863268bafb39b3d9517575b1"
]
},
@ -917,7 +930,7 @@
"nodeSelector": {},
"images": [
"quay.io/oauth2-proxy/oauth2-proxy:v7.15.3@sha256:10a1165743a192e1940b4708fb9647027185ce11a681a1c5519b442ff7f1f561",
"registry.bstein.dev/bstein/hermes-agent@sha256:3154e1df5f6941583f4a369fcc9581bbf9cb9d502676e91b557e90cc576923e1"
"registry.bstein.dev/bstein/hermes-agent@sha256:cce1f65dc7d30fdce4b1748cc7d3434b9761ae9946a8c8be6049204c503d60a8"
]
},
{
@ -1164,7 +1177,7 @@
"serviceAccountName": "hermes-chat",
"nodeSelector": {},
"images": [
"registry.bstein.dev/bstein/hermes-agent@sha256:3154e1df5f6941583f4a369fcc9581bbf9cb9d502676e91b557e90cc576923e1",
"registry.bstein.dev/bstein/hermes-agent@sha256:cce1f65dc7d30fdce4b1748cc7d3434b9761ae9946a8c8be6049204c503d60a8",
"registry.bstein.dev/bstein/hermes-webui@sha256:9c2fe8341c7b650e08d10acead3151b19e2af737863268bafb39b3d9517575b1"
]
},
@ -1515,7 +1528,7 @@
"kubernetes.io/os": "linux"
},
"images": [
"registry.bstein.dev/bstein/metis-sentinel:0.1.0-292-amd64"
"registry.bstein.dev/bstein/metis-sentinel:0.1.0-293-amd64"
]
},
{
@ -1531,7 +1544,7 @@
"kubernetes.io/os": "linux"
},
"images": [
"registry.bstein.dev/bstein/metis-sentinel:0.1.0-291-arm64"
"registry.bstein.dev/bstein/metis-sentinel:0.1.0-293-arm64"
]
},
{
@ -1648,7 +1661,7 @@
"serviceAccountName": "metis",
"nodeSelector": {},
"images": [
"registry.bstein.dev/bstein/metis:0.1.0-291-arm64"
"registry.bstein.dev/bstein/metis:0.1.0-293-arm64"
]
},
{

View File

@ -596,6 +596,15 @@ workloads:
images:
- nginx:1.27.5-alpine@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10
- wger/server@sha256:710588b78af4e0aa0b4d8a8061e4563e16eae80eeaccfe7f9e0d9cbdd7f0cbc5
- kind: DaemonSet
namespace: hermes
name: hermes-node-ssh-access
labels:
app: hermes-node-ssh-access
serviceAccountName: hermes-node-ssh-access
nodeSelector: {}
images:
- busybox:1.37
- kind: Deployment
namespace: hermes
name: hermes
@ -604,7 +613,7 @@ workloads:
serviceAccountName: hermes-triage
nodeSelector: {}
images:
- registry.bstein.dev/bstein/hermes-agent@sha256:3154e1df5f6941583f4a369fcc9581bbf9cb9d502676e91b557e90cc576923e1
- registry.bstein.dev/bstein/hermes-agent@sha256:cce1f65dc7d30fdce4b1748cc7d3434b9761ae9946a8c8be6049204c503d60a8
- registry.bstein.dev/bstein/hermes-webui@sha256:9c2fe8341c7b650e08d10acead3151b19e2af737863268bafb39b3d9517575b1
- kind: Deployment
namespace: hermes
@ -615,7 +624,7 @@ workloads:
nodeSelector: {}
images:
- quay.io/oauth2-proxy/oauth2-proxy:v7.15.3@sha256:10a1165743a192e1940b4708fb9647027185ce11a681a1c5519b442ff7f1f561
- registry.bstein.dev/bstein/hermes-agent@sha256:3154e1df5f6941583f4a369fcc9581bbf9cb9d502676e91b557e90cc576923e1
- registry.bstein.dev/bstein/hermes-agent@sha256:cce1f65dc7d30fdce4b1748cc7d3434b9761ae9946a8c8be6049204c503d60a8
- kind: Deployment
namespace: hermes
name: hermes-chat-router
@ -788,7 +797,7 @@ workloads:
serviceAccountName: hermes-chat
nodeSelector: {}
images:
- registry.bstein.dev/bstein/hermes-agent@sha256:3154e1df5f6941583f4a369fcc9581bbf9cb9d502676e91b557e90cc576923e1
- registry.bstein.dev/bstein/hermes-agent@sha256:cce1f65dc7d30fdce4b1748cc7d3434b9761ae9946a8c8be6049204c503d60a8
- registry.bstein.dev/bstein/hermes-webui@sha256:9c2fe8341c7b650e08d10acead3151b19e2af737863268bafb39b3d9517575b1
- kind: Deployment
namespace: jellyfin
@ -1028,7 +1037,7 @@ workloads:
kubernetes.io/arch: amd64
kubernetes.io/os: linux
images:
- registry.bstein.dev/bstein/metis-sentinel:0.1.0-292-amd64
- registry.bstein.dev/bstein/metis-sentinel:0.1.0-293-amd64
- kind: DaemonSet
namespace: maintenance
name: metis-sentinel-arm64
@ -1039,7 +1048,7 @@ workloads:
kubernetes.io/arch: arm64
kubernetes.io/os: linux
images:
- registry.bstein.dev/bstein/metis-sentinel:0.1.0-291-arm64
- registry.bstein.dev/bstein/metis-sentinel:0.1.0-293-arm64
- kind: DaemonSet
namespace: maintenance
name: node-image-sweeper
@ -1118,7 +1127,7 @@ workloads:
serviceAccountName: metis
nodeSelector: {}
images:
- registry.bstein.dev/bstein/metis:0.1.0-291-arm64
- registry.bstein.dev/bstein/metis:0.1.0-293-arm64
- kind: Deployment
namespace: maintenance
name: oauth2-proxy-metis

File diff suppressed because one or more lines are too long

View File

@ -135,11 +135,10 @@ alert fires instead.
## Credentials
- Ariadne → Hermes: `Authorization: Bearer` key shared via the
`hermes-api-server-key` Secret present in both `hermes` and `maintenance`
namespaces (Hermes's init container seeds it into the persistent `.env`).
NOTE: currently manually created (Vault migration pending — see plan
handoff); rotating = write new value to both Secrets, restart hermes
deployment and ariadne deployment.
- Ariadne → Hermes: `Authorization: Bearer` key from
`kv/atlas/hermes/triage-api`. Vault Agent injects it into both workloads at
runtime; it is not stored in either persistent Hermes state or a manually
maintained Kubernetes Secret. Rotate the Vault value and roll both tracked
deployments through Flux.
- Ariadne → Jenkins: existing `JENKINS_API_USER/TOKEN` from Vault
(`atlas/maintenance/ariadne-db`).

View File

@ -81,6 +81,25 @@ generate_token() {
--raw 2>/dev/null
}
token_is_valid() {
username=$1
token=$2
[ -n "${token}" ] || return 1
response_file=$(mktemp)
status=$(curl --silent --show-error \
--output "${response_file}" \
--write-out '%{http_code}' \
--header "Authorization: token ${token}" \
"${api_url}/user")
if [ "${status}" = 200 ] && grep -Eq "\"login\"[[:space:]]*:[[:space:]]*\"${username}\"" "${response_file}"; then
rm -f "${response_file}"
return 0
fi
rm -f "${response_file}"
return 1
}
api_request() {
method=$1
path=$2
@ -143,6 +162,31 @@ vault_login() {
[ -n "${vault_token}" ] || die "Vault login did not return a client token"
}
vault_read_field() {
path=$1
field=$2
response_file=$(mktemp)
status=$(curl --silent --show-error \
--output "${response_file}" \
--write-out '%{http_code}' \
--header "X-Vault-Token: ${vault_token}" \
"${vault_addr}/v1/kv/data/atlas/${path}")
case "${status}" in
200)
value=$(sed -n "s/.*\"${field}\":\"\([^\"]*\)\".*/\1/p" "${response_file}")
;;
404)
value=
;;
*)
rm -f "${response_file}"
die "read Vault path ${path} returned HTTP ${status}"
;;
esac
rm -f "${response_file}"
printf '%s' "${value}"
}
vault_write() {
path=$1
payload=$2
@ -231,9 +275,16 @@ transfer_managed_repositories() {
wait_for_gitea
ensure_user "${reconciler_user}" "${reconciler_email}" true
ensure_user "${hermes_user}" "${hermes_email}" false
vault_login
reconciler_token=$(generate_token "${reconciler_user}")
hermes_token=$(generate_token "${hermes_user}")
reconciler_token=$(vault_read_field gitea/atlas-reconciler token)
if ! token_is_valid "${reconciler_user}" "${reconciler_token}"; then
reconciler_token=$(generate_token "${reconciler_user}")
fi
hermes_token=$(vault_read_field hermes/developer-gitea token)
if ! token_is_valid "${hermes_user}" "${hermes_token}"; then
hermes_token=$(generate_token "${hermes_user}")
fi
[ -n "${reconciler_token}" ] || die "reconciler token generation failed"
[ -n "${hermes_token}" ] || die "Hermes token generation failed"
@ -267,7 +318,6 @@ expect_status "${status}" 204 "add Hermes to Atlas contributors"
transfer_managed_repositories
vault_login
vault_write gitea/atlas-reconciler "{\"data\":{\"username\":\"${reconciler_user}\",\"token\":\"${reconciler_token}\",\"base_url\":\"${public_url}\"}}"
vault_write hermes/developer-gitea "{\"data\":{\"username\":\"${hermes_user}\",\"email\":\"${hermes_email}\",\"token\":\"${hermes_token}\",\"base_url\":\"${public_url}\"}}"

View File

@ -299,6 +299,19 @@ data:
impact, avoid fleet-wide destructive commands, and reflect persistent host
configuration in the appropriate tracked provisioning source.
Treat every credential, SSH identity, host-trust record, and
credential-bearing access-client state as runtime-only Vault data. Keep
non-secret endpoints, RBAC policy, and client behavior in tracked GitOps
configuration, but never reconstruct secret or trust values in a manifest,
commit, transcript, or command output. Use dedicated Kubernetes-auth roles
with Vault Agent or Secrets Store CSI injection for workloads. For an
explicitly authorized operator Vault change, obtain the existing admin
credential from the `vault` namespace at execution time, keep it only in
process scope, suppress value-bearing output, and unset it immediately
afterward. Verify public-key fingerprints through an already authenticated
path before changing trust or authorization, and rotate an identity if any
private material may have escaped its runtime boundary.
After a failed tool attempt, failing test, contradicted claim, rejected
review, or incomplete evidence, do not repeat the same low-capability plan.
Reassess the objective and raise provider capability or effort by at least
@ -319,8 +332,9 @@ data:
Use `/route status` to inspect the current decision, `/route auto`
for automatic routing, or `/route manual <codex|claude>
<low|medium|high|xhigh> [model]` for a persistent override. The first native
Codex worker requires one device-code login; subsequent sessions persist on
the agent volume. The owner workspace includes cluster-admin Kubernetes
Codex worker requires one device-code login; refreshed provider credentials
persist through Vault while client caches remain disposable. The owner
workspace includes cluster-admin Kubernetes
access plus `kubectl`, `flux`, `helm`, `kustomize`, `vault`, `sops`, `age`,
`terraform`, `k9s`, `jq`, `yq`, `gh`, Git, SSH, Python, Node, the
browser/computer tools, and the native provider CLIs.

View File

@ -25,13 +25,23 @@ spec:
ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers
ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback
ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available
ai.bstein.dev/config-rev: "20260815-worker-restart-recovery"
ai.bstein.dev/config-rev: "20260815-runtime-access-boundary"
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: |
vault.hashicorp.com/agent-inject-secret-agent-api-key: kv/data/atlas/hermes/agent-tokens
vault.hashicorp.com/agent-inject-template-agent-api-key: |
{{- with secret "kv/data/atlas/hermes/agent-tokens" -}}
{{ .Data.data.anthropic_oauth_token }}
{{ .Data.data.agent_api_key }}
{{- end }}
vault.hashicorp.com/agent-inject-secret-claude-credentials: kv/data/atlas/hermes/agent-tokens
vault.hashicorp.com/agent-inject-template-claude-credentials: |
{{- with secret "kv/data/atlas/hermes/agent-tokens" -}}
{{ .Data.data.claude_credentials_json }}
{{- end }}
vault.hashicorp.com/agent-inject-secret-codex-auth: kv/data/atlas/hermes/agent-tokens
vault.hashicorp.com/agent-inject-template-codex-auth: |
{{- with secret "kv/data/atlas/hermes/agent-tokens" -}}
{{ .Data.data.codex_auth_json }}
{{- end }}
vault.hashicorp.com/agent-inject-secret-gitea-token: kv/data/atlas/hermes/developer-gitea
vault.hashicorp.com/agent-inject-template-gitea-token: |
@ -140,15 +150,16 @@ spec:
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
cp /vault/secrets/node-ssh-config /opt/data/home/.ssh/config
cp /vault/secrets/node-ssh-known-hosts /opt/data/home/.ssh/known_hosts
if [ -s /vault/secrets/node-ssh-private-key ]; then
cp /vault/secrets/node-ssh-private-key /opt/data/home/.ssh/id_ed25519_atlas_nodes
chmod 0600 /opt/data/home/.ssh/id_ed25519_atlas_nodes
fi
chmod 0600 \
rm -f \
/opt/data/auth.json \
/opt/data/home/.claude/.credentials.json \
/opt/data/home/.codex/auth.json \
/opt/data/home/.ssh/config \
/opt/data/home/.ssh/known_hosts
/opt/data/home/.ssh/known_hosts \
/opt/data/home/.ssh/id_ed25519_atlas_nodes
ln -s /runtime-access/node-ssh-config /opt/data/home/.ssh/config
ln -s /runtime-access/node-ssh-known-hosts /opt/data/home/.ssh/known_hosts
ln -s /runtime-access/node-ssh-private-key /opt/data/home/.ssh/id_ed25519_atlas_nodes
chmod 0700 /opt/data/home/.ssh
touch "${env_file}"
upsert_env() {
@ -157,26 +168,19 @@ spec:
{ 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 ""|"<no value>"|"<nil>") ;; *) upsert_env GITEA_TOKEN "${token}" ;; esac
fi
if [ -s /vault/secrets/gitea-username ]; then
username="$(tr -d '\r\n' < /vault/secrets/gitea-username)"
case "${username}" in ""|"<no value>"|"<nil>") ;; *) upsert_env GITEA_USERNAME "${username}" ;; esac
fi
if [ -s /vault/secrets/chat-relay-key ]; then
token="$(tr -d '\r\n' < /vault/secrets/chat-relay-key)"
[ -z "${token}" ] || upsert_env HERMES_IMAGE_BROKER_KEY "${token}"
fi
for key in ANTHROPIC_API_KEY API_SERVER_KEY CLAUDE_API_KEY CLAUDE_CODE_OAUTH_TOKEN GITEA_TOKEN GITEA_USERNAME HERMES_IMAGE_BROKER_KEY OPENAI_API_KEY; do
grep -v "^${key}=" "${env_file}" > "${env_file}.tmp" || true
mv "${env_file}.tmp" "${env_file}"
done
for profile_env in /opt/data/profiles/*/.env; do
[ -f "${profile_env}" ] || continue
for key in ANTHROPIC_API_KEY API_SERVER_KEY CLAUDE_API_KEY CLAUDE_CODE_OAUTH_TOKEN GITEA_TOKEN GITEA_USERNAME HERMES_IMAGE_BROKER_KEY OPENAI_API_KEY; do
grep -v "^${key}=" "${profile_env}" > "${profile_env}.tmp" || true
mv "${profile_env}.tmp" "${profile_env}"
done
chmod 0600 "${profile_env}"
chown 10000:10000 "${profile_env}"
done
upsert_env GIT_ASKPASS /opt/coordinator/gitea_askpass.sh
upsert_env GIT_TERMINAL_PROMPT 0
chmod 0600 "${env_file}"
@ -191,8 +195,6 @@ spec:
/opt/data/home/.codex \
/opt/data/home/.kube \
/opt/data/home/.ssh \
/opt/data/home/.ssh/config \
/opt/data/home/.ssh/known_hosts \
/opt/data/cli-lanes \
/opt/data/logs \
/opt/data/provider-health \
@ -207,9 +209,6 @@ spec:
/opt/data/workspace/AGENTS.md \
/opt/data/workspace/START-HERE.md \
"${env_file}"
if [ -f /opt/data/home/.ssh/id_ed25519_atlas_nodes ]; then
chown 10000:10000 /opt/data/home/.ssh/id_ed25519_atlas_nodes
fi
securityContext:
allowPrivilegeEscalation: false
runAsUser: 0
@ -225,6 +224,26 @@ spec:
resources:
requests: {cpu: 25m, memory: 32Mi}
limits: {cpu: 100m, memory: 64Mi}
- name: stage-runtime-access
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent
command:
- /opt/hermes/.venv/bin/python
- /opt/coordinator/stage_runtime_access.py
- agent
securityContext:
allowPrivilegeEscalation: false
runAsUser: 0
runAsGroup: 0
seccompProfile:
type: RuntimeDefault
volumeMounts:
- {name: home, mountPath: /opt/data}
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: runtime-access, mountPath: /runtime-access}
resources:
requests: {cpu: 25m, memory: 32Mi}
limits: {cpu: 100m, memory: 64Mi}
- name: install-agent-tools
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent
@ -380,6 +399,52 @@ spec:
resources:
requests: {cpu: 25m, memory: 64Mi}
limits: {cpu: 100m, memory: 128Mi}
- name: patch-web-session-activity
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent
command:
- /opt/hermes/.venv/bin/python
- /opt/coordinator/patch_web_session_activity.py
- /opt/hermes/hermes_cli/web_server.py
- /patched/web_server.py
securityContext:
allowPrivilegeEscalation: false
runAsUser: 10000
runAsGroup: 10000
seccompProfile:
type: RuntimeDefault
volumeMounts:
- name: coordinator
mountPath: /opt/coordinator
readOnly: true
- name: web-server-patch
mountPath: /patched
resources:
requests: {cpu: 25m, memory: 64Mi}
limits: {cpu: 100m, memory: 128Mi}
- name: patch-subprocess-secret-boundary
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent
command:
- /bin/sh
- -ec
- |
/opt/hermes/.venv/bin/python /opt/coordinator/patch_subprocess_secret_boundary.py \
/opt/hermes/tools/environments/local.py /patched/local.py
/opt/hermes/.venv/bin/python /opt/coordinator/patch_process_output_redaction.py \
/opt/hermes/tools/process_registry.py /patched/process_registry.py
securityContext:
allowPrivilegeEscalation: false
runAsUser: 10000
runAsGroup: 10000
seccompProfile:
type: RuntimeDefault
volumeMounts:
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: subprocess-secret-patch, mountPath: /patched}
resources:
requests: {cpu: 25m, memory: 64Mi}
limits: {cpu: 100m, memory: 128Mi}
- name: patch-codex-runtime
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent
@ -422,10 +487,10 @@ spec:
- --once
env:
- {name: HERMES_HOME, value: /opt/data}
- {name: HERMES_AUTH_FILE, value: /shared-auth/auth.json}
- {name: HERMES_AUTH_FILE, value: /runtime-access/hermes-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: CODEX_HOME, value: /runtime-access/codex}
- {name: CLAUDE_CONFIG_DIR, value: /runtime-access/claude}
- {name: PYTHONPATH, value: /opt/hermes}
- {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json}
- {name: HERMES_CASSANDRA_ACTIVE_WORKTREE, value: /opt/data/workspace/projects/cassandra-hermes-v69}
@ -438,7 +503,7 @@ spec:
type: RuntimeDefault
volumeMounts:
- {name: home, mountPath: /opt/data}
- {name: provider-auth, mountPath: /shared-auth}
- {name: runtime-access, mountPath: /runtime-access}
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
- {name: routing-catalog, mountPath: /routing-catalog}
@ -464,10 +529,10 @@ spec:
/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: HERMES_AUTH_FILE, value: /runtime-access/hermes-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: CODEX_HOME, value: /runtime-access/codex}
- {name: CLAUDE_CONFIG_DIR, value: /runtime-access/claude}
- {name: PYTHONPATH, value: /opt/hermes}
- {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json}
- {name: HERMES_CASSANDRA_ACTIVE_WORKTREE, value: /opt/data/workspace/projects/cassandra-hermes-v69}
@ -480,7 +545,7 @@ spec:
type: RuntimeDefault
volumeMounts:
- {name: home, mountPath: /opt/data}
- {name: provider-auth, mountPath: /shared-auth}
- {name: runtime-access, mountPath: /runtime-access}
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
- {name: routing-catalog, mountPath: /routing-catalog}
@ -518,16 +583,19 @@ spec:
set -a
. /opt/data/.env
set +a
API_SERVER_KEY="$(tr -d '\r\n' < /runtime-access/agent-api-key)"
test -n "${API_SERVER_KEY}"
export API_SERVER_KEY
exec /init /opt/hermes/docker/main-wrapper.sh gateway run
ports:
- {name: api, containerPort: 8642, protocol: TCP}
- {name: dashboard, containerPort: 9119, protocol: TCP}
env:
- {name: HERMES_HOME, value: /opt/data}
- {name: HERMES_AUTH_FILE, value: /shared-auth/auth.json}
- {name: HERMES_AUTH_FILE, value: /runtime-access/hermes-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: CODEX_HOME, value: /runtime-access/codex}
- {name: CLAUDE_CONFIG_DIR, value: /runtime-access/claude}
- {name: KUBECONFIG, value: /opt/data/home/.kube/config}
- {name: PATH, value: /opt/coordinator:/opt/data/tools/bin:/opt/data/home/.local/bin:/opt/hermes/.venv/bin:/opt/hermes/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin}
- {name: PLAYWRIGHT_BROWSERS_PATH, value: /opt/hermes/.playwright}
@ -551,11 +619,13 @@ spec:
- {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json}
- {name: HERMES_CODEX_HEALTH_PATH, value: /opt/data/provider-health/codex.json}
- {name: HERMES_CLAUDE_HEALTH_PATH, value: /opt/data/provider-health/claude.json}
- {name: HERMES_IMAGE_BROKER_KEY_FILE, value: /runtime-access/chat-relay-key}
volumeMounts:
- {name: home, mountPath: /opt/data}
- {name: provider-auth, mountPath: /shared-auth}
- {name: runtime-access, mountPath: /runtime-access}
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
- {name: web-server-patch, mountPath: /opt/hermes/hermes_cli/web_server.py, subPath: web_server.py}
- {name: codex-runtime-patch, mountPath: /opt/hermes/hermes_cli/runtime_provider.py, subPath: runtime_provider.py}
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/transports/codex_app_server_session.py, subPath: codex_app_server_session.py}
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/codex_runtime.py, subPath: codex_runtime.py}
@ -564,6 +634,8 @@ spec:
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/auxiliary_client.py, subPath: auxiliary_client.py}
- {name: tui-gateway-patch, mountPath: /opt/hermes/tui_gateway/server.py, subPath: server.py}
- {name: api-server-patch, mountPath: /opt/hermes/gateway/platforms/api_server.py, subPath: api_server.py}
- {name: subprocess-secret-patch, mountPath: /opt/hermes/tools/environments/local.py, subPath: local.py}
- {name: subprocess-secret-patch, mountPath: /opt/hermes/tools/process_registry.py, subPath: process_registry.py}
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}
- {name: routing-catalog, mountPath: /routing-catalog, readOnly: true}
@ -699,9 +771,9 @@ spec:
env:
- {name: HOME, value: /opt/data/home}
- {name: HERMES_HOME, value: /opt/data}
- {name: HERMES_AUTH_FILE, value: /shared-auth/auth.json}
- {name: CODEX_HOME, value: /opt/data/home/.codex}
- {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude}
- {name: HERMES_AUTH_FILE, value: /runtime-access/hermes-auth.json}
- {name: CODEX_HOME, value: /runtime-access/codex}
- {name: CLAUDE_CONFIG_DIR, value: /runtime-access/claude}
- {name: KUBECONFIG, value: /opt/data/home/.kube/config}
- {name: PYTHONPATH, value: /opt/hermes}
- {name: PATH, value: /opt/coordinator:/opt/data/tools/bin:/opt/data/home/.local/bin:/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin}
@ -715,7 +787,7 @@ spec:
- {name: HERMES_CLAUDE_HEALTH_PATH, value: /opt/data/provider-health/claude.json}
volumeMounts:
- {name: home, mountPath: /opt/data}
- {name: provider-auth, mountPath: /shared-auth}
- {name: runtime-access, mountPath: /runtime-access}
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
- {name: codex-runtime-patch, mountPath: /opt/hermes/hermes_cli/runtime_provider.py, subPath: runtime_provider.py}
@ -725,6 +797,8 @@ spec:
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/conversation_loop.py, subPath: conversation_loop.py}
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/auxiliary_client.py, subPath: auxiliary_client.py}
- {name: tui-gateway-patch, mountPath: /opt/hermes/tui_gateway/server.py, subPath: server.py}
- {name: subprocess-secret-patch, mountPath: /opt/hermes/tools/environments/local.py, subPath: local.py}
- {name: subprocess-secret-patch, mountPath: /opt/hermes/tools/process_registry.py, subPath: process_registry.py}
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}
- {name: routing-catalog, mountPath: /routing-catalog, readOnly: true}
@ -769,10 +843,10 @@ spec:
exec /opt/hermes/.venv/bin/python /opt/coordinator/cli_lane_runner.py
env:
- {name: HERMES_HOME, value: /opt/data}
- {name: HERMES_AUTH_FILE, value: /shared-auth/auth.json}
- {name: HERMES_AUTH_FILE, value: /runtime-access/hermes-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: CODEX_HOME, value: /runtime-access/codex}
- {name: CLAUDE_CONFIG_DIR, value: /runtime-access/claude}
- {name: KUBECONFIG, value: /opt/data/home/.kube/config}
- {name: PYTHONPATH, value: /opt/hermes}
- {name: HERMES_CLI_LANE_CONCURRENCY, value: "4"}
@ -790,7 +864,7 @@ spec:
type: RuntimeDefault
volumeMounts:
- {name: home, mountPath: /opt/data}
- {name: provider-auth, mountPath: /shared-auth}
- {name: runtime-access, mountPath: /runtime-access}
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
- {name: kubeconfig, mountPath: /opt/data/home/.kube/config, subPath: config, readOnly: true}
@ -805,10 +879,10 @@ spec:
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: HERMES_AUTH_FILE, value: /runtime-access/hermes-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: CODEX_HOME, value: /runtime-access/codex}
- {name: CLAUDE_CONFIG_DIR, value: /runtime-access/claude}
- {name: PYTHONPATH, value: /opt/hermes}
- {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json}
- {name: HERMES_CASSANDRA_ACTIVE_WORKTREE, value: /opt/data/workspace/projects/cassandra-hermes-v69}
@ -821,33 +895,57 @@ spec:
type: RuntimeDefault
volumeMounts:
- {name: home, mountPath: /opt/data}
- {name: provider-auth, mountPath: /shared-auth}
- {name: runtime-access, mountPath: /runtime-access}
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
- {name: routing-catalog, mountPath: /routing-catalog}
resources:
requests: {cpu: 25m, memory: 64Mi}
limits: {cpu: 250m, memory: 512Mi}
- name: credential-sync
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent
command:
- /opt/hermes/.venv/bin/python
- /opt/coordinator/sync_runtime_credentials.py
env:
- {name: HERMES_CREDENTIAL_SYNC_VAULT_ROLE, value: hermes-credential-sync}
- {name: HERMES_CREDENTIAL_SYNC_INTERVAL, value: "60"}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10000
runAsGroup: 10000
seccompProfile:
type: RuntimeDefault
volumeMounts:
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: runtime-access, mountPath: /runtime-access/claude, subPath: claude, readOnly: true}
- {name: runtime-access, mountPath: /runtime-access/codex, subPath: codex, readOnly: true}
resources:
requests: {cpu: 10m, memory: 32Mi}
limits: {cpu: 100m, memory: 128Mi}
- name: image-broker
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent
command: [/bin/sh, -ec]
args:
- |
set -a
. /opt/data/.env
set +a
exec /opt/hermes/.venv/bin/python /opt/coordinator/image_broker.py
ports:
- {name: image-broker, containerPort: 9002, protocol: TCP}
env:
- {name: HERMES_HOME, value: /opt/data}
- {name: HERMES_AUTH_FILE, value: /shared-auth/auth.json}
- {name: HERMES_AUTH_FILE, value: /runtime-access/hermes-auth.json}
- {name: HOME, value: /opt/data/home}
- {name: CODEX_HOME, value: /opt/data/home/.codex}
- {name: CODEX_HOME, value: /runtime-access/codex}
- {name: PYTHONPATH, value: /opt/hermes}
- {name: HERMES_IMAGE_BROKER_DEFAULT_MODEL, value: atlas-image-auto-high}
- {name: HERMES_IMAGE_BROKER_LISTEN_PORT, value: "9002"}
- {name: HERMES_IMAGE_BROKER_KEY_FILE, value: /runtime-access/chat-relay-key}
- {name: HERMES_LOCAL_IMAGE_URL, value: http://hermes-local-image.hermes.svc.cluster.local:9004}
- {name: HERMES_IMAGE_POLICY_PATH, value: /etc/hermes-image-policy/policy.json}
readinessProbe:
@ -870,7 +968,7 @@ spec:
type: RuntimeDefault
volumeMounts:
- {name: home, mountPath: /opt/data}
- {name: provider-auth, mountPath: /shared-auth}
- {name: runtime-access, mountPath: /runtime-access}
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/auxiliary_client.py, subPath: auxiliary_client.py}
- {name: image-policy, mountPath: /etc/hermes-image-policy, readOnly: true}
@ -884,19 +982,17 @@ spec:
command: [/bin/sh, -ec]
args:
- |
set -a
. /opt/data/.env
set +a
exec /opt/hermes/.venv/bin/python /opt/coordinator/codex_broker.py
ports:
- {name: codex-broker, containerPort: 9003, protocol: TCP}
env:
- {name: HERMES_HOME, value: /opt/data}
- {name: HERMES_AUTH_FILE, value: /shared-auth/auth.json}
- {name: HERMES_AUTH_FILE, value: /runtime-access/hermes-auth.json}
- {name: HOME, value: /opt/data/home}
- {name: CODEX_HOME, value: /opt/data/home/.codex}
- {name: CODEX_HOME, value: /runtime-access/codex}
- {name: PYTHONPATH, value: /opt/hermes}
- {name: HERMES_CODEX_BROKER_LISTEN_PORT, value: "9003"}
- {name: HERMES_CODEX_BROKER_KEY_FILE, value: /runtime-access/chat-relay-key}
- {name: HERMES_CODEX_HEALTH_PATH, value: /opt/data/provider-health/codex.json}
- {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json}
readinessProbe:
@ -919,7 +1015,7 @@ spec:
type: RuntimeDefault
volumeMounts:
- {name: home, mountPath: /opt/data}
- {name: provider-auth, mountPath: /shared-auth}
- {name: runtime-access, mountPath: /runtime-access}
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: codex-runtime-patch, mountPath: /opt/hermes/agent/auxiliary_client.py, subPath: auxiliary_client.py}
- {name: tmp, mountPath: /tmp}
@ -933,23 +1029,21 @@ spec:
command: [/bin/sh, -ec]
args:
- |
set -a
. /opt/data/.env
set +a
unset ANTHROPIC_API_KEY CLAUDE_API_KEY
exec /opt/hermes/.venv/bin/python /opt/coordinator/claude_oauth_broker.py
ports:
- {name: claude-broker, containerPort: 9006, protocol: TCP}
env:
- {name: HERMES_HOME, value: /opt/data}
- {name: HERMES_AUTH_FILE, value: /shared-auth/auth.json}
- {name: HERMES_AUTH_FILE, value: /runtime-access/hermes-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: CODEX_HOME, value: /runtime-access/codex}
- {name: CLAUDE_CONFIG_DIR, value: /runtime-access/claude}
- {name: PYTHONPATH, value: /opt/hermes}
- {name: PATH, value: /opt/coordinator:/opt/data/tools/bin:/opt/data/home/.local/bin:/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin}
- {name: HERMES_CLAUDE_BIN, value: /opt/coordinator/claude}
- {name: HERMES_CLAUDE_BROKER_PORT, value: "9006"}
- {name: HERMES_CLAUDE_BROKER_KEY_FILE, value: /runtime-access/chat-relay-key}
- {name: HERMES_CLAUDE_BROKER_READ_TIMEOUT, value: "1800"}
- {name: HERMES_CLAUDE_HEALTH_PATH, value: /opt/data/provider-health/claude.json}
- {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json}
@ -973,7 +1067,7 @@ spec:
type: RuntimeDefault
volumeMounts:
- {name: home, mountPath: /opt/data}
- {name: provider-auth, mountPath: /shared-auth}
- {name: runtime-access, mountPath: /runtime-access}
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
- {name: tmp, mountPath: /tmp}
@ -985,9 +1079,10 @@ spec:
- name: home
persistentVolumeClaim:
claimName: hermes-agent-home
- name: provider-auth
persistentVolumeClaim:
claimName: hermes-provider-auth
- name: runtime-access
emptyDir:
medium: Memory
sizeLimit: 128Mi
- name: routing-catalog
persistentVolumeClaim:
claimName: hermes-routing-catalog
@ -1008,6 +1103,10 @@ spec:
emptyDir: {}
- name: api-server-patch
emptyDir: {}
- name: web-server-patch
emptyDir: {}
- name: subprocess-secret-patch
emptyDir: {}
- name: codex-runtime-patch
emptyDir: {}
- name: auto-router-plugin

View File

@ -20,7 +20,7 @@ spec:
app: hermes-chat-router
annotations:
ai.bstein.dev/role: privacy-preserving-chat-tenant-router
ai.bstein.dev/config-rev: "20260813-telegram-token-v3"
ai.bstein.dev/config-rev: "20260815-runtime-access-boundary"
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/agent-pre-populate-only: "true"
vault.hashicorp.com/agent-init-first: "true"

View File

@ -29,14 +29,9 @@ spec:
ai.bstein.dev/router-wire-contract: ollama-numeric-keepalive
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: "20260812-stream-recovery"
ai.bstein.dev/config-rev: "20260815-runtime-access-boundary"
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" -}}
@ -111,26 +106,11 @@ spec:
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
{ grep -v '^HERMES_IMAGE_BROKER_KEY=' /opt/data/.env || true; printf 'HERMES_IMAGE_BROKER_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
rm -f /vault/secrets/anthropic-token /vault/secrets/chat-relay-key
for key in ANTHROPIC_API_KEY API_SERVER_KEY CLAUDE_API_KEY CLAUDE_CODE_OAUTH_TOKEN GITEA_TOKEN GITEA_USERNAME HERMES_IMAGE_BROKER_KEY OPENAI_API_KEY; do
grep -v "^${key}=" /opt/data/.env > /opt/data/.env.tmp || true
mv /opt/data/.env.tmp /opt/data/.env
done
rm -f /opt/data/auth.json
chmod 0600 /opt/data/.env
chown 10000:10000 \
/opt/data \
@ -157,6 +137,25 @@ spec:
resources:
requests: {cpu: 25m, memory: 32Mi}
limits: {cpu: 100m, memory: 64Mi}
- name: stage-runtime-access
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent
command:
- /opt/hermes/.venv/bin/python
- /opt/coordinator/stage_runtime_access.py
- chat
securityContext:
allowPrivilegeEscalation: false
runAsUser: 0
runAsGroup: 0
seccompProfile:
type: RuntimeDefault
volumeMounts:
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: runtime-access, mountPath: /runtime-access}
resources:
requests: {cpu: 25m, memory: 32Mi}
limits: {cpu: 100m, memory: 64Mi}
- name: patch-auth
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent
@ -197,6 +196,29 @@ spec:
resources:
requests: {cpu: 25m, memory: 64Mi}
limits: {cpu: 100m, memory: 128Mi}
- name: patch-subprocess-secret-boundary
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent
command:
- /bin/sh
- -ec
- |
/opt/hermes/.venv/bin/python /opt/coordinator/patch_subprocess_secret_boundary.py \
/opt/hermes/tools/environments/local.py /patched/local.py
/opt/hermes/.venv/bin/python /opt/coordinator/patch_process_output_redaction.py \
/opt/hermes/tools/process_registry.py /patched/process_registry.py
securityContext:
allowPrivilegeEscalation: false
runAsUser: 10000
runAsGroup: 10000
seccompProfile:
type: RuntimeDefault
volumeMounts:
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: subprocess-secret-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:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
@ -209,12 +231,15 @@ spec:
set -a
. /opt/data/.env
set +a
API_SERVER_KEY="$(tr -d '\r\n' < /runtime-access/chat-relay-key)"
test -n "${API_SERVER_KEY}"
export API_SERVER_KEY
exec /opt/hermes/.venv/bin/hermes 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: HERMES_AUTH_FILE, value: /runtime-access/hermes-auth.json}
- {name: HOME, value: /opt/data/home}
- {name: TERMINAL_CWD, value: /opt/data/workspace}
- {name: HERMES_WRITE_SAFE_ROOT, value: /opt/data/workspace}
@ -228,16 +253,16 @@ spec:
- {name: API_SERVER_PORT, value: "8642"}
- {name: API_SERVER_CORS_ORIGINS, value: https://chat.hermes.bstein.dev}
- {name: HERMES_IMAGE_BROKER_URL, value: http://hermes-image-broker.hermes.svc.cluster.local:9002}
- {name: HERMES_IMAGE_BROKER_KEY_FILE, value: /runtime-access/chat-relay-key}
- {name: HERMES_AUTO_ROUTER_PROFILE, value: chat}
volumeMounts:
- {name: home, mountPath: /opt/data}
- {name: workspace, mountPath: /opt/data/workspace}
# The shared provider pool uses auth.lock to serialize token refresh
# across tenant gateways. Tenant files and conversations remain on
# their own PVCs; only provider credentials are shared here.
- {name: provider-auth, mountPath: /shared-auth}
- {name: runtime-access, mountPath: /runtime-access}
- {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
- {name: stream-recovery-patch, mountPath: /opt/hermes/agent/conversation_loop.py, subPath: conversation_loop.py}
- {name: subprocess-secret-patch, mountPath: /opt/hermes/tools/environments/local.py, subPath: local.py}
- {name: subprocess-secret-patch, mountPath: /opt/hermes/tools/process_registry.py, subPath: process_registry.py}
- {name: image-plugin, mountPath: /opt/hermes/plugins/image_gen/atlas-broker, readOnly: true}
- {name: auto-router-plugin, mountPath: /opt/data/plugins/auto-router, readOnly: true}
readinessProbe:
@ -265,7 +290,7 @@ spec:
command: [/bin/sh, -ec]
args:
- |
api_key="$(sed -n 's/^API_SERVER_KEY=//p' /opt/data/.env | tail -n 1)"
api_key="$(tr -d '\r\n' < /runtime-access/chat-relay-key)"
test -n "${api_key}"
export API_SERVER_KEY="${api_key}"
export HERMES_WEBUI_GATEWAY_API_KEY="${api_key}"
@ -274,7 +299,7 @@ spec:
- {name: webui, containerPort: 8787, protocol: TCP}
env:
- {name: HERMES_HOME, value: /opt/data}
- {name: HERMES_AUTH_FILE, value: /shared-auth/auth.json}
- {name: HERMES_AUTH_FILE, value: /runtime-access/hermes-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}
@ -302,7 +327,7 @@ spec:
volumeMounts:
- {name: home, mountPath: /opt/data}
- {name: workspace, mountPath: /opt/data/workspace}
- {name: provider-auth, mountPath: /shared-auth, readOnly: true}
- {name: runtime-access, mountPath: /runtime-access, readOnly: true}
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: tmp, mountPath: /tmp}
readinessProbe:
@ -338,11 +363,12 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.name
- {name: HERMES_MEDIA_RELAY_KEY_FILE, value: /opt/data/.env}
- {name: HERMES_MEDIA_RELAY_KEY_FILE, value: /runtime-access/chat-relay-key}
volumeMounts:
- {name: home, mountPath: /opt/data, readOnly: true}
- {name: workspace, mountPath: /opt/data/workspace, readOnly: true}
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: runtime-access, mountPath: /runtime-access, readOnly: true}
readinessProbe:
httpGet: {path: /healthz, port: telegram-media}
initialDelaySeconds: 2
@ -364,9 +390,10 @@ spec:
requests: {cpu: 10m, memory: 24Mi}
limits: {cpu: 100m, memory: 64Mi}
volumes:
- name: provider-auth
persistentVolumeClaim:
claimName: hermes-provider-auth
- name: runtime-access
emptyDir:
medium: Memory
sizeLimit: 2Mi
- name: config
configMap:
name: hermes-chat-config
@ -378,6 +405,8 @@ spec:
emptyDir: {}
- name: stream-recovery-patch
emptyDir: {}
- name: subprocess-secret-patch
emptyDir: {}
- name: auto-router-plugin
configMap:
name: hermes-auto-router-plugin

View File

@ -25,17 +25,13 @@ spec:
ai.bstein.dev/model: anthropic/claude-opus-5, falling back to openai-codex/gpt-5.6-terra then titan-20 Qwen 14B
ai.bstein.dev/role: testing-triage
ai.bstein.dev/placement: titan-21 preferred, Jetson preferred, arm64 fallback
ai.bstein.dev/config-rev: "20260813-api-lineage-cleanup"
# 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
# this pod's service account.
ai.bstein.dev/config-rev: "20260815-runtime-access-boundary"
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: hermes
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 }}
vault.hashicorp.com/agent-inject-secret-triage-api-key: kv/data/atlas/hermes/triage-api
vault.hashicorp.com/agent-inject-template-triage-api-key: |
{{- with secret "kv/data/atlas/hermes/triage-api" -}}
{{ .Data.data.api_key }}
{{- end }}
vault.hashicorp.com/agent-pre-populate-only: "true"
# Without this the agent init container is appended after the
@ -99,24 +95,6 @@ spec:
- name: init-config
image: busybox:1.37
imagePullPolicy: IfNotPresent
env:
# When the Flux/Vault-managed shared key Secret exists, it becomes
# the API_SERVER_KEY in the persistent .env (which overrides pod
# env at runtime). Optional: absent Secret keeps the old behavior
# of generating a random key on first boot.
- name: API_SERVER_KEY_SEED
valueFrom:
secretKeyRef:
name: hermes-api-server-key
key: api-key
optional: true
# Fallback only; Vault is preferred when its file is present.
- name: CLAUDE_CODE_OAUTH_TOKEN_SEED
valueFrom:
secretKeyRef:
name: hermes-anthropic-token
key: token
optional: true
command:
- sh
- -c
@ -135,37 +113,13 @@ spec:
cp /guide/OPERATOR-RUNBOOK.md /opt/data/HERMES-OPERATOR-RUNBOOK.md
cp /config/ATLAS-TRIAGE-PROOFS.md /opt/data/ATLAS-TRIAGE-PROOFS.md
touch /opt/data/.env
if [ -n "${API_SERVER_KEY_SEED:-}" ]; then
grep -v '^API_SERVER_KEY=' /opt/data/.env > /opt/data/.env.tmp || true
printf 'API_SERVER_KEY=%s\n' "${API_SERVER_KEY_SEED}" >> /opt/data/.env.tmp
for key in ANTHROPIC_API_KEY API_SERVER_KEY CLAUDE_API_KEY CLAUDE_CODE_OAUTH_TOKEN GITEA_TOKEN GITEA_USERNAME HERMES_IMAGE_BROKER_KEY OPENAI_API_KEY; do
grep -v "^${key}=" /opt/data/.env > /opt/data/.env.tmp || true
mv /opt/data/.env.tmp /opt/data/.env
fi
if ! grep -q '^API_SERVER_KEY=' /opt/data/.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}" >> /opt/data/.env
fi
# Anthropic credential: Vault first, then the manual Secret. The
# Secret is retained only as a rollback path while the migration
# settles; delete it once Vault has been serving for a while.
anthropic=""
if [ -r /vault/secrets/anthropic-token ]; then
anthropic="$(cat /vault/secrets/anthropic-token)"
elif [ -n "${CLAUDE_CODE_OAUTH_TOKEN_SEED:-}" ]; then
anthropic="${CLAUDE_CODE_OAUTH_TOKEN_SEED}"
fi
if [ -n "${anthropic}" ]; then
grep -v '^CLAUDE_CODE_OAUTH_TOKEN=' /opt/data/.env > /opt/data/.env.tmp || true
printf 'CLAUDE_CODE_OAUTH_TOKEN=%s\n' "${anthropic}" >> /opt/data/.env.tmp
mv /opt/data/.env.tmp /opt/data/.env
fi
done
rm -f /opt/data/auth.json
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
@ -176,8 +130,6 @@ spec:
mountPath: /config
- name: operator-guide
mountPath: /guide
- name: provider-auth
mountPath: /shared-auth
resources:
requests:
cpu: 25m
@ -185,6 +137,25 @@ spec:
limits:
cpu: 100m
memory: 64Mi
- name: stage-runtime-access
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent
command:
- /opt/hermes/.venv/bin/python
- /opt/coordinator/stage_runtime_access.py
- triage
securityContext:
allowPrivilegeEscalation: false
runAsUser: 0
runAsGroup: 0
seccompProfile:
type: RuntimeDefault
volumeMounts:
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: runtime-access, mountPath: /runtime-access}
resources:
requests: {cpu: 25m, memory: 32Mi}
limits: {cpu: 100m, memory: 64Mi}
- name: patch-auth
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent
@ -239,6 +210,29 @@ spec:
resources:
requests: {cpu: 25m, memory: 64Mi}
limits: {cpu: 100m, memory: 128Mi}
- name: patch-subprocess-secret-boundary
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent
command:
- /bin/sh
- -ec
- |
/opt/hermes/.venv/bin/python /opt/coordinator/patch_subprocess_secret_boundary.py \
/opt/hermes/tools/environments/local.py /patched/local.py
/opt/hermes/.venv/bin/python /opt/coordinator/patch_process_output_redaction.py \
/opt/hermes/tools/process_registry.py /patched/process_registry.py
securityContext:
allowPrivilegeEscalation: false
runAsUser: 10000
runAsGroup: 10000
seccompProfile:
type: RuntimeDefault
volumeMounts:
- {name: coordinator, mountPath: /opt/coordinator, readOnly: true}
- {name: subprocess-secret-patch, mountPath: /patched}
resources:
requests: {cpu: 25m, memory: 64Mi}
limits: {cpu: 100m, memory: 128Mi}
- name: install-kubectl
image: bitnami/kubectl@sha256:554ab88b1858e8424c55de37ad417b16f2a0e65d1607aa0f3fe3ce9b9f10b131
imagePullPolicy: IfNotPresent
@ -267,11 +261,13 @@ spec:
- name: hermes
image: registry.bstein.dev/bstein/hermes-agent@sha256:81970563e542f0720773e72297810b3a844b83e381e278f25c0916c78d930107
imagePullPolicy: IfNotPresent
command: [/opt/hermes/.venv/bin/hermes]
command: [/bin/sh, -ec]
args:
- gateway
- run
- --no-supervise
- |
API_SERVER_KEY="$(tr -d '\r\n' < /runtime-access/triage-api-key)"
test -n "${API_SERVER_KEY}"
export API_SERVER_KEY
exec /opt/hermes/.venv/bin/hermes gateway run --no-supervise
ports:
- name: api
containerPort: 8642
@ -280,7 +276,7 @@ spec:
- name: HERMES_HOME
value: /opt/data
- name: HERMES_AUTH_FILE
value: /shared-auth/auth.json
value: /runtime-access/hermes-auth.json
- name: HOME
value: /opt/data/home
- name: PATH
@ -313,14 +309,6 @@ spec:
value: automated-triage
- name: HERMES_API_DEFAULT_PARENT_MATCH_PREFIXES
value: "A static-analysis finding, not a build failure.||Use $triage-titan-test-failures."
# Claude subscription OAuth token (sk-ant-oat01...). The anthropic
# provider accepts ANTHROPIC_API_KEY, ANTHROPIC_TOKEN, or this, in
# that order; an OAuth token is not an API key, so it must arrive
# under this name. Optional, so Hermes still starts without it and
# falls back to openai-codex.
# TODO: migrate to Vault alongside the Gitea token; this manual
# Secret was created in the interest of demo time.
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
@ -333,14 +321,20 @@ spec:
volumeMounts:
- name: home
mountPath: /opt/data
- name: provider-auth
mountPath: /shared-auth
- name: runtime-access
mountPath: /runtime-access
- name: auth-patch
mountPath: /opt/hermes/hermes_cli/auth.py
subPath: auth.py
- name: api-server-patch
mountPath: /opt/hermes/gateway/platforms/api_server.py
subPath: api_server.py
- name: subprocess-secret-patch
mountPath: /opt/hermes/tools/environments/local.py
subPath: local.py
- name: subprocess-secret-patch
mountPath: /opt/hermes/tools/process_registry.py
subPath: process_registry.py
- name: tools
mountPath: /usr/local/bin/kubectl
subPath: kubectl
@ -390,7 +384,7 @@ spec:
command: [/bin/sh, -ec]
args:
- |
api_key="$(sed -n 's/^API_SERVER_KEY=//p' /opt/data/.env | tail -n 1)"
api_key="$(tr -d '\r\n' < /runtime-access/triage-api-key)"
test -n "${api_key}"
export API_SERVER_KEY="${api_key}"
export HERMES_WEBUI_GATEWAY_API_KEY="${api_key}"
@ -399,7 +393,7 @@ spec:
- {name: dashboard, containerPort: 8787, protocol: TCP}
env:
- {name: HERMES_HOME, value: /opt/data}
- {name: HERMES_AUTH_FILE, value: /shared-auth/auth.json}
- {name: HERMES_AUTH_FILE, value: /runtime-access/hermes-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}
@ -417,7 +411,7 @@ spec:
- {name: HERMES_ROUTER_PROFILE, value: triage}
volumeMounts:
- {name: home, mountPath: /opt/data}
- {name: provider-auth, mountPath: /shared-auth, readOnly: true}
- {name: runtime-access, mountPath: /runtime-access, readOnly: true}
- {name: tmp, mountPath: /tmp}
readinessProbe:
httpGet: {path: /health, port: dashboard}
@ -446,9 +440,10 @@ spec:
- name: home
persistentVolumeClaim:
claimName: hermes-home
- name: provider-auth
persistentVolumeClaim:
claimName: hermes-provider-auth
- name: runtime-access
emptyDir:
medium: Memory
sizeLimit: 2Mi
- name: config
configMap:
name: hermes-config
@ -468,6 +463,8 @@ spec:
emptyDir: {}
- name: api-server-patch
emptyDir: {}
- name: subprocess-secret-patch
emptyDir: {}
- name: tmp
emptyDir:
sizeLimit: 256Mi

View File

@ -4,7 +4,7 @@ kind: Kustomization
namespace: hermes
images:
- name: registry.bstein.dev/bstein/hermes-agent
digest: sha256:cce1f65dc7d30fdce4b1748cc7d3434b9761ae9946a8c8be6049204c503d60a8
digest: sha256:0d9faa7970e4166eb3d06772decc570a695e36ca27327e4a0e8074aa05030b78
resources:
- namespace.yaml
- vault-serviceaccount.yaml
@ -76,6 +76,11 @@ configMapGenerator:
- migrate_herdr_state.py=scripts/migrate_herdr_state.py
- migrate_api_session_lineage.py=scripts/migrate_api_session_lineage.py
- patch_api_server_sessions.py=scripts/patch_api_server_sessions.py
- patch_web_session_activity.py=scripts/patch_web_session_activity.py
- patch_subprocess_secret_boundary.py=scripts/patch_subprocess_secret_boundary.py
- patch_process_output_redaction.py=scripts/patch_process_output_redaction.py
- stage_runtime_access.py=scripts/stage_runtime_access.py
- sync_runtime_credentials.py=scripts/sync_runtime_credentials.py
- patch_hermes_auth.py=scripts/patch_hermes_auth.py
- patch_codex_runtime.py=scripts/patch_codex_runtime.py
- patch_stream_recovery.py=scripts/patch_stream_recovery.py

View File

@ -159,15 +159,17 @@ HOSTED_EDIT_SCHEMA = {
def _broker_key() -> str:
"""Load the internal relay key from process env or the private .env."""
value = os.environ.get("HERMES_IMAGE_BROKER_KEY", "").strip()
if value:
return value
home = Path(os.environ.get("HERMES_HOME", "/opt/data"))
"""Load the internal relay key from its runtime-only Vault file."""
secret_path = Path(
os.environ.get(
"HERMES_IMAGE_BROKER_KEY_FILE",
"/runtime-access/chat-relay-key",
)
)
try:
for line in (home / ".env").read_text(encoding="utf-8").splitlines():
if line.startswith("HERMES_IMAGE_BROKER_KEY="):
return line.split("=", 1)[1].strip()
value = secret_path.read_text(encoding="utf-8").strip()
if value:
return value
except OSError:
pass
return ""

View File

@ -31,21 +31,6 @@ spec:
---
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

View File

@ -5,5 +5,5 @@ set -eu
exec /opt/data/tools/bin/claude \
--dangerously-skip-permissions \
--autocompact auto \
--settings /opt/data/home/.claude/settings.json \
--settings "${CLAUDE_CONFIG_DIR:-/runtime-access/claude}/settings.json" \
"$@"

View File

@ -66,11 +66,8 @@ _auth_probe_at = 0.0
_auth_probe_value: dict[str, Any] = {}
def _read_secret(env_name: str, file_env_name: str) -> str:
"""Read a secret from an environment value or mounted file."""
value = os.environ.get(env_name, "").strip()
if value:
return value
def _read_secret(file_env_name: str) -> str:
"""Read a secret only from its runtime-mounted file."""
path = os.environ.get(file_env_name, "").strip()
if not path:
return ""
@ -82,10 +79,7 @@ def _read_secret(env_name: str, file_env_name: str) -> str:
def _relay_key() -> str:
"""Return the internal relay key shared with Switchyard."""
return (
_read_secret("HERMES_CLAUDE_BROKER_KEY", "HERMES_CLAUDE_BROKER_KEY_FILE")
or os.environ.get("HERMES_IMAGE_BROKER_KEY", "").strip()
)
return _read_secret("HERMES_CLAUDE_BROKER_KEY_FILE")
def _authorized(authorization: str | None, api_key: str | None) -> bool:

View File

@ -23,7 +23,22 @@ from routing_catalog import resolve_route
HOST = os.environ.get("HERMES_CODEX_BROKER_LISTEN_HOST", "0.0.0.0")
PORT = int(os.environ.get("HERMES_CODEX_BROKER_LISTEN_PORT", "9003"))
TOKEN = os.environ.get("HERMES_IMAGE_BROKER_KEY", "").strip()
def _relay_token() -> str:
"""Read relay auth without exporting it to Codex subprocesses."""
path = Path(
os.environ.get(
"HERMES_CODEX_BROKER_KEY_FILE", "/runtime-access/chat-relay-key"
)
)
try:
return path.read_text(encoding="utf-8").strip()
except OSError:
return ""
TOKEN = _relay_token()
UPSTREAM = os.environ.get(
"HERMES_CODEX_BROKER_UPSTREAM",
"https://chatgpt.com/backend-api/codex",
@ -613,7 +628,7 @@ class Handler(BaseHTTPRequestHandler):
def main() -> None:
"""Serve until Kubernetes terminates the sidecar."""
if not TOKEN:
raise SystemExit("HERMES_IMAGE_BROKER_KEY is required")
raise SystemExit("runtime relay key is unavailable")
try:
token = _access_token()
except RuntimeError as exc:

View File

@ -2,7 +2,18 @@
set -eu
case "${1:-}" in
*Username*) printf '%s\n' "${GITEA_USERNAME:-hermes-automation}" ;;
*Password*) printf '%s\n' "${GITEA_TOKEN:-}" ;;
*Username*)
if [ -s /runtime-access/gitea-username ]; then
tr -d '\r\n' </runtime-access/gitea-username
else
printf '%s' hermes-automation
fi
printf '\n'
;;
*Password*)
test -s /runtime-access/gitea-token
tr -d '\r\n' </runtime-access/gitea-token
printf '\n'
;;
*) exit 1 ;;
esac

View File

@ -25,6 +25,7 @@ from hermes_model_routing import (
CASSANDRA_BASE_PATH = Path("/opt/data/workspace/projects/cassandra")
CASSANDRA_REMOTE = "https://scm.bstein.dev/atlas/cassandra.git"
DEFAULT_GITEA_TOKEN_PATH = Path("/runtime-access/gitea-token")
def cassandra_workspace() -> Path:
@ -138,13 +139,44 @@ def bootstrap_cassandra_state(root: Path) -> dict[str, str]:
return {"state": "ready"}
def _gitea_token_path() -> Path:
"""Return the runtime-only Gitea credential path without reading it."""
configured = os.environ.get("HERMES_GITEA_TOKEN_FILE", "").strip()
return Path(configured) if configured else DEFAULT_GITEA_TOKEN_PATH
def _gitea_token_available() -> bool:
"""Check credential readiness without loading it into process memory."""
try:
path = _gitea_token_path()
return path.is_file() and path.stat().st_size > 0
except OSError:
return False
def sync_cassandra_repo(env_values: dict[str, str]) -> str:
"""Clone or fetch Cassandra when the optional Gitea token is available."""
token = env_values.get("GITEA_TOKEN", "").strip()
"""Clone or fetch Cassandra through the runtime-only askpass credential."""
token_available = _gitea_token_available()
if shutil.which("git") is None:
return "git-unavailable"
child_env = os.environ.copy()
child_env.update(env_values)
child_env.update(
{
key: value
for key, value in env_values.items()
if key
not in {
"API_SERVER_KEY",
"CLAUDE_CODE_OAUTH_TOKEN",
"GITEA_TOKEN",
"HERMES_IMAGE_BROKER_KEY",
}
}
)
child_env["GIT_ASKPASS"] = env_values.get(
"GIT_ASKPASS", "/opt/coordinator/gitea_askpass.sh"
)
child_env["GIT_TERMINAL_PROMPT"] = "0"
if (CASSANDRA_BASE_PATH / ".git").exists():
try:
current_remote = subprocess.run(
@ -200,7 +232,7 @@ def sync_cassandra_repo(env_values: dict[str, str]) -> str:
return f"remote-repair-failed-{repaired.returncode}"
except (OSError, subprocess.TimeoutExpired):
return "remote-repair-failed"
if not token:
if not token_available:
return "ready; fetch skipped until Gitea token is configured"
command = [
"git",
@ -215,7 +247,7 @@ def sync_cassandra_repo(env_values: dict[str, str]) -> str:
CASSANDRA_BASE_PATH.mkdir(parents=True, exist_ok=True)
if any(CASSANDRA_BASE_PATH.iterdir()):
return "unmanaged-nonempty-directory"
if not token:
if not token_available:
return "awaiting-gitea-token"
command = [
"git",
@ -244,8 +276,6 @@ def sync_cassandra_repo(env_values: dict[str, str]) -> str:
def refresh_once(root: Path) -> dict[str, Any]:
"""Refresh provider catalogs and all managed coordinator state once."""
env_values = _read_env(root / ".env")
if env_values.get("CLAUDE_CODE_OAUTH_TOKEN"):
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = env_values["CLAUDE_CODE_OAUTH_TOKEN"]
codex = discover_codex_models()
claude = discover_claude_models()
routes = configure_routes(root, codex, claude)

View File

@ -39,11 +39,18 @@ SWITCHYARD_API = "http://hermes-switchyard.hermes.svc.cluster.local:9005/v1"
SWITCHYARD_AUTO_ROUTE = "atlas/auto/maximum"
ROUTING_CATALOG_PATH = os.environ.get("HERMES_ROUTING_CATALOG_PATH", "").strip()
MANAGED_ENV_KEYS = {
"GIT_ASKPASS",
"GIT_TERMINAL_PROMPT",
}
RUNTIME_SECRET_ENV_KEYS = {
"ANTHROPIC_API_KEY",
"API_SERVER_KEY",
"CLAUDE_API_KEY",
"CLAUDE_CODE_OAUTH_TOKEN",
"GITEA_TOKEN",
"GITEA_USERNAME",
"GIT_ASKPASS",
"GIT_TERMINAL_PROMPT",
"HERMES_IMAGE_BROKER_KEY",
"OPENAI_API_KEY",
}
@ -359,7 +366,7 @@ def _read_env(path: Path) -> dict[str, str]:
def _update_profile_env(path: Path, source: dict[str, str]) -> None:
"""Refresh managed credentials while preserving user-owned environment keys."""
"""Refresh non-secret managed settings and remove stale credential copies."""
try:
old_lines = path.read_text(encoding="utf-8").splitlines()
except OSError:
@ -367,7 +374,10 @@ def _update_profile_env(path: Path, source: dict[str, str]) -> None:
kept = [
line
for line in old_lines
if not any(line.lstrip().startswith(f"{key}=") for key in MANAGED_ENV_KEYS)
if not any(
line.lstrip().startswith(f"{key}=")
for key in MANAGED_ENV_KEYS | RUNTIME_SECRET_ENV_KEYS
)
]
kept.extend(
f"{key}={source[key]}" for key in sorted(MANAGED_ENV_KEYS) if source.get(key)
@ -494,25 +504,13 @@ def discover_claude_models() -> Catalog:
)
except (OSError, subprocess.SubprocessError, ValueError, json.JSONDecodeError):
pass
token = str(os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") or "").strip()
live: list[str] = []
if token:
try:
from hermes_cli.models import _fetch_anthropic_models
live = _unique_models(_fetch_anthropic_models(timeout=10.0) or [])
except Exception:
live = []
if live:
return Catalog("anthropic", live, True, True, "connected")
try:
from hermes_cli.models import provider_model_ids
known = _unique_models(provider_model_ids("anthropic", force_refresh=True))
except Exception:
known = []
state = "degraded" if token else "not-configured"
return Catalog("anthropic", known, False, bool(token), state)
return Catalog("anthropic", known, False, False, "not-configured")
def _profile_config(
@ -626,8 +624,6 @@ def configure_routes(
env_values = _read_env(root / ".env")
routes: dict[str, list[str]] = {}
for effort in EFFORTS:
codex_model = codex_models[effort]
claude_model = claude_models[effort]
codex_name = f"codex-{effort}"
claude_name = f"claude-{effort}"
codex_route = {

View File

@ -20,7 +20,22 @@ HOST = os.environ.get("HERMES_IMAGE_BROKER_LISTEN_HOST", "0.0.0.0")
# Kubernetes reserves <SERVICE>_PORT for service-link discovery and injects a
# tcp://... value. Keep listener configuration out of that namespace.
PORT = int(os.environ.get("HERMES_IMAGE_BROKER_LISTEN_PORT", "9002"))
TOKEN = os.environ.get("HERMES_IMAGE_BROKER_KEY", "").strip()
def _relay_token() -> str:
"""Read relay auth without exporting it to provider subprocesses."""
path = Path(
os.environ.get(
"HERMES_IMAGE_BROKER_KEY_FILE", "/runtime-access/chat-relay-key"
)
)
try:
return path.read_text(encoding="utf-8").strip()
except OSError:
return ""
TOKEN = _relay_token()
PROVIDER_PATH = Path(
os.environ.get(
"HERMES_IMAGE_PROVIDER_PATH",
@ -329,7 +344,7 @@ class Handler(BaseHTTPRequestHandler):
def main() -> None:
"""Serve until Kubernetes terminates the sidecar."""
if not TOKEN:
raise SystemExit("HERMES_IMAGE_BROKER_KEY is required")
raise SystemExit("runtime relay key is unavailable")
server = ThreadingHTTPServer((HOST, PORT), Handler)
server.serve_forever()

View File

@ -129,11 +129,20 @@ RUN_CLOSE_AFTER = ''' finally:
# distinguish a long model wait from completed or failed work.
if parent_session_id:
try:
terminal_status = self._run_statuses.get(run_id, {}).get(
"status", "failed"
)
activity_status = terminal_status if terminal_status in {
"completed",
"failed",
"cancelled",
} else "failed"
self._record_run_activity(
session_id,
f"run.{activity_status}",
)
db = self._ensure_session_db()
if db is not None:
terminal_status = self._run_statuses.get(run_id, {}).get(
"status", "failed"
)
db.end_session(session_id, f"api_run_{terminal_status}")
except Exception:
logger.exception(
@ -145,6 +154,156 @@ RUN_CLOSE_AFTER = ''' finally:
# If the asyncio wrapper is cancelled (for example via
'''
EVENT_CALLBACK_SIGNATURE_BEFORE = ''' def _make_run_event_callback(self, run_id: str, loop: "asyncio.AbstractEventLoop"):
'''
EVENT_CALLBACK_SIGNATURE_AFTER = ''' _RUN_ACTIVITY_BYTES = 512_000
_RUN_ACTIVITY_FILES = 256
def _record_run_activity(
self,
session_id: str,
event_type: str,
*,
tool_name: str = None,
preview: str = None,
is_error: bool = False,
) -> None:
"""Append a bounded, display-only event without changing agent history."""
labels = {
"run.started": "Worker started",
"run.completed": "Worker completed",
"run.failed": "Worker failed",
"run.cancelled": "Worker cancelled",
"_thinking": "Hermes is reasoning",
"reasoning.available": "Hermes finished a reasoning step",
"tool.started": "Tool started",
"tool.completed": "Tool failed" if is_error else "Tool finished",
"subagent.tool": "Nested worker tool",
"subagent.progress": "Nested worker progress",
"subagent_progress": "Nested worker progress",
"subagent.complete": "Nested worker finished",
"subagent.text": "Nested worker produced an update",
"subagent.thinking": "Nested worker is reasoning",
}
label = labels.get(event_type)
if not session_id or not label:
return
# Tool names are bounded identifiers. All previews are arbitrary model
# or tool text and can contain prompts, arguments, paths, or credentials,
# so the activity projection must never persist them.
detail = tool_name if event_type in {
"tool.started",
"tool.completed",
"subagent.tool",
} else ""
try:
detail = redact_sensitive_text(str(detail)).strip()[:480]
content = f"{label}: {detail}" if detail else label
entry = {
"role": "assistant",
"content": f"Activity · {content}",
"timestamp": time.time(),
"activity_event": event_type,
}
root = Path(
os.environ.get("HERMES_HOME", "~/.hermes")
).expanduser() / "run-activity"
if root.is_symlink():
return
root.mkdir(mode=0o700, parents=True, exist_ok=True)
root.chmod(0o700)
digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest()
path = root / f"{digest}.jsonl"
previous = root / f"{digest}.previous.jsonl"
if path.is_symlink() or previous.is_symlink():
return
new_file = not path.exists()
if path.exists() and path.stat().st_size >= self._RUN_ACTIVITY_BYTES:
previous.unlink(missing_ok=True)
path.replace(previous)
line = (json.dumps(entry, separators=(",", ":")) + "\\n").encode()
flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND
flags |= getattr(os, "O_NOFOLLOW", 0)
fd = os.open(path, flags, 0o600)
try:
os.fchmod(fd, 0o600)
os.write(fd, line)
finally:
os.close(fd)
if new_file:
files = sorted(
root.glob("*.jsonl"),
key=lambda item: item.stat().st_mtime,
)
for stale in files[:-self._RUN_ACTIVITY_FILES]:
if not stale.is_symlink():
stale.unlink(missing_ok=True)
except Exception:
logger.debug("Could not record run activity", exc_info=True)
def _make_run_event_callback(
self,
run_id: str,
loop: "asyncio.AbstractEventLoop",
*,
session_id: str,
):
'''
EVENT_CALLBACK_BODY_BEFORE = ''' def _callback(event_type: str, tool_name: str = None, preview: str = None, args=None, **kwargs):
ts = time.time()
if event_type == "tool.started":
'''
EVENT_CALLBACK_BODY_AFTER = ''' def _callback(event_type: str, tool_name: str = None, preview: str = None, args=None, **kwargs):
ts = time.time()
self._record_run_activity(
session_id,
event_type,
tool_name=tool_name,
preview=preview,
is_error=bool(kwargs.get("is_error", False)),
)
if event_type == "tool.started":
'''
EVENT_CALLBACK_END_BEFORE = ''' # _thinking and subagent_progress are intentionally not forwarded
'''
EVENT_CALLBACK_END_AFTER = ''' elif event_type in {
"subagent.tool",
"subagent.progress",
"subagent_progress",
"subagent.complete",
"subagent.text",
"subagent.thinking",
}:
safe_preview = (
redact_sensitive_text(str(tool_name or ""))[:480]
if event_type == "subagent.tool"
else ""
)
_push({
"event": event_type,
"run_id": run_id,
"timestamp": ts,
"tool": tool_name if event_type == "subagent.tool" else None,
"preview": safe_preview,
})
'''
EVENT_CALLBACK_CALL_BEFORE = ''' event_cb = self._make_run_event_callback(run_id, loop)
'''
EVENT_CALLBACK_CALL_AFTER = ''' event_cb = self._make_run_event_callback(
run_id,
loop,
session_id=session_id,
)
self._record_run_activity(session_id, "run.started")
'''
def patch(source: Path, destination: Path) -> None:
"""Apply the narrow session-lineage extension and fail on upstream drift."""
@ -155,11 +314,27 @@ def patch(source: Path, destination: Path) -> None:
raise RuntimeError("Hermes API runs patch context changed")
if RUN_CLOSE_BEFORE not in content:
raise RuntimeError("Hermes API run-close patch context changed")
for marker, message in (
(EVENT_CALLBACK_SIGNATURE_BEFORE, "event callback signature"),
(EVENT_CALLBACK_BODY_BEFORE, "event callback body"),
(EVENT_CALLBACK_END_BEFORE, "event callback end"),
(EVENT_CALLBACK_CALL_BEFORE, "event callback call"),
):
if marker not in content:
raise RuntimeError(f"Hermes API {message} patch context changed")
destination.parent.mkdir(parents=True, exist_ok=True)
content = content.replace(BEFORE, AFTER, 1)
content = content.replace(RUNS_BEFORE, RUNS_AFTER, 1)
content = content.replace(RUN_CLOSE_BEFORE, RUN_CLOSE_AFTER, 1)
content = content.replace(
EVENT_CALLBACK_SIGNATURE_BEFORE,
EVENT_CALLBACK_SIGNATURE_AFTER,
1,
)
content = content.replace(EVENT_CALLBACK_BODY_BEFORE, EVENT_CALLBACK_BODY_AFTER, 1)
content = content.replace(EVENT_CALLBACK_END_BEFORE, EVENT_CALLBACK_END_AFTER, 1)
destination.write_text(
content.replace(RUN_CLOSE_BEFORE, RUN_CLOSE_AFTER, 1),
content.replace(EVENT_CALLBACK_CALL_BEFORE, EVENT_CALLBACK_CALL_AFTER, 1),
encoding="utf-8",
)

View File

@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Redact credential assignments from every background-process result."""
from __future__ import annotations
import argparse
from pathlib import Path
BEFORE = ''' result[field] = redact_terminal_output(value, command)
'''
AFTER = ''' result[field] = redact_sensitive_text(
redact_terminal_output(value, command),
code_file=False,
)
'''
def patch(source: Path, destination: Path) -> None:
"""Apply unconditional result redaction and fail on upstream drift."""
content = source.read_text(encoding="utf-8")
if content.count(BEFORE) != 1:
raise RuntimeError("Hermes process-result redaction context changed")
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(content.replace(BEFORE, AFTER, 1), encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path)
parser.add_argument("destination", type=Path)
args = parser.parse_args()
patch(args.source, args.destination)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Keep Atlas runtime credentials out of Hermes-spawned subprocesses."""
from __future__ import annotations
import argparse
from pathlib import Path
BEFORE = ''' upper = key.upper()
if upper.startswith("AUXILIARY_") and (
'''
AFTER = ''' upper = key.upper()
if upper in {
"API_SERVER_KEY",
"CLAUDE_CODE_OAUTH_TOKEN",
"GITEA_TOKEN",
"HERMES_IMAGE_BROKER_KEY",
}:
return True
if upper.startswith("AUXILIARY_") and (
'''
def patch(source: Path, destination: Path) -> None:
"""Apply the runtime-credential boundary and fail on upstream drift."""
content = source.read_text(encoding="utf-8")
if content.count(BEFORE) != 1:
raise RuntimeError("Hermes subprocess secret boundary context changed")
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(content.replace(BEFORE, AFTER, 1), encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path)
parser.add_argument("destination", type=Path)
args = parser.parse_args()
patch(args.source, args.destination)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Merge display-only worker activity into dashboard session transcripts."""
from __future__ import annotations
import argparse
from pathlib import Path
HELPER_MARKER = '''@app.get("/api/sessions/{session_id}/messages")
async def get_session_messages(session_id: str, profile: Optional[str] = None):
'''
HELPER_REPLACEMENT = '''def _run_activity_messages(session_id: str) -> List[Dict[str, Any]]:
"""Read a bounded activity journal without treating it as agent history."""
digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest()
root = get_hermes_home() / "run-activity"
if root.is_symlink():
return []
paths = (
root / f"{digest}.previous.jsonl",
root / f"{digest}.jsonl",
)
entries: List[Dict[str, Any]] = []
for path in paths:
try:
if path.is_symlink() or not path.is_file():
continue
if path.stat().st_size > 600_000:
continue
for line in path.read_text(encoding="utf-8").splitlines():
item = json.loads(line)
if not isinstance(item, dict):
continue
content = item.get("content")
timestamp = item.get("timestamp")
if not isinstance(content, str) or not isinstance(
timestamp, (int, float)
):
continue
entries.append(
{
"role": "assistant",
"content": content[:1_000],
"timestamp": float(timestamp),
"observed": True,
}
)
except (OSError, UnicodeError, ValueError, json.JSONDecodeError):
continue
return entries[-1_000:]
@app.get("/api/sessions/{session_id}/messages")
async def get_session_messages(session_id: str, profile: Optional[str] = None):
'''
MESSAGES_BEFORE = ''' messages = db.get_messages(sid)
return {"session_id": sid, "messages": messages}
'''
MESSAGES_AFTER = ''' messages = [
*db.get_messages(sid),
*_run_activity_messages(sid),
]
messages.sort(key=lambda item: float(item.get("timestamp") or 0.0))
return {"session_id": sid, "messages": messages}
'''
def patch(source: Path, destination: Path) -> None:
"""Apply the activity projection and fail closed on upstream drift."""
content = source.read_text(encoding="utf-8")
if HELPER_MARKER not in content:
raise RuntimeError("Hermes dashboard activity helper context changed")
if MESSAGES_BEFORE not in content:
raise RuntimeError("Hermes dashboard messages context changed")
content = content.replace(HELPER_MARKER, HELPER_REPLACEMENT, 1)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(
content.replace(MESSAGES_BEFORE, MESSAGES_AFTER, 1),
encoding="utf-8",
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path)
parser.add_argument("destination", type=Path)
args = parser.parse_args()
patch(args.source, args.destination)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Stage Vault-projected access material into a pod-lifetime filesystem."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
VAULT_ROOT = Path("/vault/secrets")
RUNTIME_ROOT = Path("/runtime-access")
PERSISTENT_HOME = Path("/opt/data/home")
OWNER_UID = 10000
OWNER_GID = 10000
def _owned_directory(path: Path) -> None:
"""Create one private directory owned by the unprivileged Hermes user."""
path.mkdir(mode=0o700, parents=True, exist_ok=True)
path.chmod(0o700)
os.chown(path, OWNER_UID, OWNER_GID)
def _copy_secret(source_name: str, destination: Path) -> str:
"""Copy a non-empty Vault projection without logging its value."""
value = (VAULT_ROOT / source_name).read_text(encoding="utf-8").strip()
if not value:
raise RuntimeError(f"Vault projection {source_name} is empty")
destination.write_text(value + "\n", encoding="utf-8")
destination.chmod(0o600)
os.chown(destination, OWNER_UID, OWNER_GID)
return value
def _validated_json(source_name: str, destination: Path, required: tuple[str, ...]) -> None:
"""Stage one credential document after checking its expected shape."""
value = _copy_secret(source_name, destination)
try:
document = json.loads(value)
except json.JSONDecodeError:
destination.unlink(missing_ok=True)
raise
current = document
for key in required:
if not isinstance(current, dict) or key not in current:
destination.unlink(missing_ok=True)
raise RuntimeError(f"Vault projection {source_name} has an invalid shape")
current = current[key]
if not isinstance(current, str) or not current:
destination.unlink(missing_ok=True)
raise RuntimeError(f"Vault projection {source_name} has an empty credential")
def _link_noncredential_state(
source: Path,
destination: Path,
allowed: tuple[str, ...],
) -> None:
"""Expose only an explicit allowlist of persistent, non-credential state."""
if not source.is_dir():
return
for name in allowed:
item = source / name
if not item.exists() or item.is_symlink():
continue
target = destination / name
if target.exists() or target.is_symlink():
continue
target.symlink_to(item)
def _write_empty_auth_store() -> None:
"""Create a private, non-credential Hermes auth store for Switchyard routes."""
path = RUNTIME_ROOT / "hermes-auth.json"
path.write_text(
json.dumps(
{"version": 1, "providers": {}, "credential_pool": {}},
separators=(",", ":"),
)
+ "\n",
encoding="utf-8",
)
path.chmod(0o600)
os.chown(path, OWNER_UID, OWNER_GID)
def stage_agent() -> None:
"""Stage the owner agent's complete runtime access set."""
for path in (RUNTIME_ROOT, RUNTIME_ROOT / "claude", RUNTIME_ROOT / "codex"):
_owned_directory(path)
for name in (
"agent-api-key",
"chat-relay-key",
"gitea-token",
"gitea-username",
"node-ssh-private-key",
"node-ssh-config",
"node-ssh-known-hosts",
):
_copy_secret(name, RUNTIME_ROOT / name)
_validated_json(
"claude-credentials",
RUNTIME_ROOT / "claude" / ".credentials.json",
("claudeAiOauth", "refreshToken"),
)
_validated_json(
"codex-auth",
RUNTIME_ROOT / "codex" / "auth.json",
("tokens", "refresh_token"),
)
_link_noncredential_state(
PERSISTENT_HOME / ".claude",
RUNTIME_ROOT / "claude",
("settings.json", "hooks", "plugins"),
)
_link_noncredential_state(
PERSISTENT_HOME / ".codex",
RUNTIME_ROOT / "codex",
(
"config.toml",
"hooks.json",
"herdr-agent-state.sh",
"rules",
"skills",
"plugins",
),
)
_write_empty_auth_store()
def stage_chat() -> None:
"""Stage only the relay credential needed by an isolated chat tenant."""
_owned_directory(RUNTIME_ROOT)
_copy_secret("chat-relay-key", RUNTIME_ROOT / "chat-relay-key")
_write_empty_auth_store()
def stage_triage() -> None:
"""Stage the automated-triage API credential without a persistent copy."""
_owned_directory(RUNTIME_ROOT)
_copy_secret("triage-api-key", RUNTIME_ROOT / "triage-api-key")
_write_empty_auth_store()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("mode", choices=("agent", "chat", "triage"))
args = parser.parse_args()
if args.mode == "agent":
stage_agent()
elif args.mode == "chat":
stage_chat()
else:
stage_triage()
print("Runtime access staged from Vault.", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Persist refreshed coding-client OAuth documents back into Vault."""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
VAULT_ADDR = os.environ.get(
"VAULT_ADDR", "http://vault.vault.svc.cluster.local:8200"
).rstrip("/")
VAULT_ROLE = os.environ.get(
"HERMES_CREDENTIAL_SYNC_VAULT_ROLE", "hermes-credential-sync"
)
SECRET_ENDPOINT = "/v1/kv/data/atlas/hermes/agent-tokens"
JWT_PATH = Path("/var/run/secrets/kubernetes.io/serviceaccount/token")
CREDENTIALS = {
"claude_credentials_json": (
Path("/runtime-access/claude/.credentials.json"),
("claudeAiOauth", "refreshToken"),
),
"codex_auth_json": (
Path("/runtime-access/codex/auth.json"),
("tokens", "refresh_token"),
),
}
def _request(
method: str,
path: str,
payload: dict[str, Any] | None = None,
*,
token: str = "",
) -> dict[str, Any]:
"""Issue one bounded Vault JSON request."""
headers = {"Content-Type": "application/json"}
if token:
headers["X-Vault-Token"] = token
request = Request(
VAULT_ADDR + path,
data=(json.dumps(payload).encode("utf-8") if payload is not None else None),
headers=headers,
method=method,
)
with urlopen(request, timeout=10) as response:
body = response.read(4 << 20)
value = json.loads(body)
if not isinstance(value, dict):
raise RuntimeError("Vault returned a non-object response")
return value
def _login() -> str:
"""Exchange the pod identity for the narrow credential-sync policy."""
response = _request(
"POST",
"/v1/auth/kubernetes/login",
{"role": VAULT_ROLE, "jwt": JWT_PATH.read_text(encoding="utf-8").strip()},
)
token = response.get("auth", {}).get("client_token")
if not isinstance(token, str) or not token:
raise RuntimeError("Vault login returned no client token")
return token
def _credential_document(path: Path, required: tuple[str, ...]) -> str | None:
"""Return one complete credential JSON document or ignore a partial write."""
try:
value = path.read_text(encoding="utf-8").strip()
document = json.loads(value)
except (OSError, UnicodeError, json.JSONDecodeError):
return None
current = document
for key in required:
if not isinstance(current, dict) or key not in current:
return None
current = current[key]
if not isinstance(current, str) or not current:
return None
return value
def sync_once(token: str) -> list[str]:
"""CAS-update only credential fields whose runtime documents changed."""
response = _request("GET", SECRET_ENDPOINT, token=token)
envelope = response.get("data")
if not isinstance(envelope, dict):
raise RuntimeError("Vault KV response has no data envelope")
data = envelope.get("data")
metadata = envelope.get("metadata")
if not isinstance(data, dict) or not isinstance(metadata, dict):
raise RuntimeError("Vault KV response has an invalid shape")
version = metadata.get("version")
if not isinstance(version, int) or version < 1:
raise RuntimeError("Vault KV response has no version")
updated = dict(data)
changed: list[str] = []
for field, (path, required) in CREDENTIALS.items():
value = _credential_document(path, required)
if value is not None and value != data.get(field):
updated[field] = value
changed.append(field)
if changed:
_request(
"POST",
SECRET_ENDPOINT,
{"options": {"cas": version}, "data": updated},
token=token,
)
return changed
def main() -> int:
"""Continuously persist refresh-token rotation without exposing values."""
interval = max(30, int(os.environ.get("HERMES_CREDENTIAL_SYNC_INTERVAL", "60")))
while True:
try:
token = _login()
changed = sync_once(token)
if changed:
print(
"Persisted refreshed runtime credentials: " + ", ".join(changed),
flush=True,
)
except (OSError, RuntimeError, HTTPError, URLError, ValueError) as error:
print(
f"Runtime credential sync deferred: {type(error).__name__}",
flush=True,
)
time.sleep(interval)
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -23,12 +23,11 @@ class MediaPathError(ValueError):
def read_relay_key(path: Path) -> str:
"""Read the shared relay key from the tenant's private environment file."""
for line in path.read_text(encoding="utf-8").splitlines():
key, separator, value = line.partition("=")
if separator and key.strip() == "API_SERVER_KEY" and value.strip():
return value.strip()
raise RuntimeError("tenant relay key is unavailable")
"""Read the shared relay key from one runtime-only secret file."""
value = path.read_text(encoding="utf-8").strip()
if not value:
raise RuntimeError("tenant relay key is unavailable")
return value
def tenant_slot(pod_name: str) -> str:
@ -179,7 +178,12 @@ class TenantMediaHandler(BaseHTTPRequestHandler):
def main() -> None:
"""Start the tenant-local media handoff server."""
relay_key_path = Path(os.getenv("HERMES_MEDIA_RELAY_KEY_FILE", "/opt/data/.env"))
relay_key_path = Path(
os.getenv(
"HERMES_MEDIA_RELAY_KEY_FILE",
"/runtime-access/chat-relay-key",
)
)
relay_key = read_relay_key(relay_key_path)
slot = tenant_slot(os.getenv("POD_NAME", ""))
server = TenantMediaServer(("0.0.0.0", 8788), relay_key, slot)

View File

@ -22,7 +22,7 @@ spec:
labels:
app: hermes-switchyard
annotations:
ai.bstein.dev/config-rev: "20260815-quality-escalation"
ai.bstein.dev/config-rev: "20260815-runtime-access-boundary"
prometheus.io/scrape: "true"
prometheus.io/port: "9005"
prometheus.io/path: /metrics

View File

@ -38,6 +38,12 @@ spec:
export GAME_MODE_HOOK_TOKEN="{{ .Data.data.game_mode_hook_token }}"
{{- end }}
{{ end }}
{{ with secret "kv/data/atlas/hermes/triage-api" }}
export ARIADNE_HERMES_API_KEY="{{ .Data.data.api_key }}"
{{ end }}
{{ with secret "kv/data/atlas/hermes/developer-gitea" }}
export ARIADNE_HERMES_GITEA_TOKEN="{{ .Data.data.token }}"
{{ end }}
{{ with secret "kv/data/atlas/portal/atlas-portal-db" }}
export PORTAL_DATABASE_URL="{{ .Data.data.PORTAL_DATABASE_URL }}"
{{ end }}
@ -489,12 +495,6 @@ spec:
value: http://hermes.hermes.svc.cluster.local:8642
- name: ARIADNE_HERMES_RUN_TIMEOUT_SECONDS
value: "420"
- name: ARIADNE_HERMES_API_KEY
valueFrom:
secretKeyRef:
name: hermes-api-server-key
key: api-key
optional: true
- name: ARIADNE_HERMES_CODE_ENABLED
value: "true"
- name: ARIADNE_HERMES_CODE_JOB
@ -605,12 +605,6 @@ spec:
value: metis=cmd/|pkg/,lesavka=server/|client/|common/,soteria=cmd/|internal/,bstein-dev-home=backend/|frontend/,ariadne=ariadne/,ananke=cmd/|internal/,pegasus=backend/|frontend/,atlasbot=atlasbot/
- name: ARIADNE_HERMES_CODE_SUFFIXES
value: metis=.go,lesavka=.rs,soteria=.go,bstein-dev-home=.py|.ts|.tsx|.js,ariadne=.py,ananke=.go,pegasus=.go|.ts|.tsx|.js,atlasbot=.py
- name: ARIADNE_HERMES_GITEA_TOKEN
valueFrom:
secretKeyRef:
name: hermes-gitea-token
key: token
optional: true
- name: ARIADNE_TESTING_TRIAGE_MODEL_URL
value: http://hermes-model-gate.hermes.svc.cluster.local:11434
- name: ARIADNE_TESTING_TRIAGE_MODEL

View File

@ -254,9 +254,11 @@ write_policy_and_role "health" "health" "health-vault-sync" \
write_policy_and_role "game-stream" "game-stream" "game-stream-vault" \
"game-stream/*" ""
write_policy_and_role "hermes" "hermes" "hermes-vault,hermes-triage" \
"hermes/triage-oidc hermes/agent-tokens" ""
"hermes/triage-oidc hermes/agent-tokens hermes/triage-api" ""
write_policy_and_role "hermes-agent" "hermes" "hermes-agent,hermes-switchyard" \
"hermes/agent-oidc hermes/agent-tokens hermes/chat-telegram hermes/developer-keycloak hermes/developer-gitea hermes/developer-harbor hermes/developer-jenkins hermes/developer-ssh" ""
write_policy_and_role "hermes-credential-sync" "hermes" "hermes-agent" \
"" "hermes/agent-tokens"
write_policy_and_role "hermes-node-ssh" "hermes" "hermes-node-ssh-access" \
"hermes/developer-ssh" ""
write_policy_and_role "hermes-chat" "hermes" "hermes-chat" \
@ -278,7 +280,7 @@ write_policy_and_role "cassandra-secrets" "cassandra" "cassandra-secrets-ensure"
write_policy_and_role "cassandra-migration" "cassandra" "cassandra-db-migration" \
"veles/veles-db cassandra/cassandra-db" ""
write_policy_and_role "maintenance" "maintenance" "ariadne,maintenance-vault-sync,metis" \
"maintenance/ariadne-db maintenance/metis-oidc maintenance/soteria-oidc maintenance/metis-ssh-keys maintenance/metis-runtime portal/atlas-portal-db portal/bstein-dev-home-keycloak-admin mailu/mailu-db-secret mailu/mailu-initial-account-secret nextcloud/nextcloud-db nextcloud/nextcloud-admin health/wger-admin finance/firefly-secrets comms/mas-admin-client-runtime comms/atlasbot-credentials-runtime comms/synapse-db comms/synapse-admin vault/vault-oidc-config shared/harbor-pull shared/soteria-restic harbor/harbor-core" "" \
"maintenance/ariadne-db maintenance/metis-oidc maintenance/soteria-oidc maintenance/metis-ssh-keys maintenance/metis-runtime portal/atlas-portal-db portal/bstein-dev-home-keycloak-admin mailu/mailu-db-secret mailu/mailu-initial-account-secret nextcloud/nextcloud-db nextcloud/nextcloud-admin health/wger-admin finance/firefly-secrets comms/mas-admin-client-runtime comms/atlasbot-credentials-runtime comms/synapse-db comms/synapse-admin vault/vault-oidc-config shared/harbor-pull shared/soteria-restic harbor/harbor-core hermes/triage-api hermes/developer-gitea" "" \
'
path "kv/data/atlas/nodes/*" {
capabilities = ["read"]

View File

@ -173,6 +173,9 @@ def test_gateway_image_honors_ui_model_and_caps_reasoning():
assert "Live worker activity" in activity
assert "api.getSessionMessages" in activity
assert "aria-live=\"polite\"" in activity
assert "role=\"log\"" in activity
assert "aria-busy={running && messages.length === 0}" in activity
assert 'detail.source !== "api_server"' not in activity
assert "Poll-backed transcript; terminal rendering is not required." in activity
assert "Open Telegram setup" in dockerfile
@ -418,7 +421,8 @@ def test_chat_image_generation_uses_private_owner_broker():
assert mounts["image-plugin"]["mountPath"] == (
"/opt/hermes/plugins/image_gen/atlas-broker"
)
assert mounts["provider-auth"].get("readOnly") is not True
assert mounts["runtime-access"]["mountPath"] == "/runtime-access"
assert "provider-auth" not in mounts
assert not any(mount["name"] == "home" and "agent" in str(mount) for mount in hermes["volumeMounts"])
env = {item["name"]: item["value"] for item in hermes["env"]}
assert env["HERMES_IMAGE_BROKER_URL"].startswith("http://hermes-image-broker.")
@ -597,7 +601,7 @@ def test_chat_reasoning_uses_switchyard_without_owner_credentials():
statefulset = _documents(HERMES / "chat-statefulset.yaml")[0]
assert statefulset["spec"]["template"]["metadata"]["annotations"][
"ai.bstein.dev/config-rev"
] == "20260812-stream-recovery"
] == "20260815-runtime-access-boundary"
pod_spec = statefulset["spec"]["template"]["spec"]
patch_init = next(
item for item in pod_spec["initContainers"]
@ -1044,6 +1048,12 @@ def test_api_session_patch_accepts_parent_lineage(tmp_path: Path):
+ module.RUNS_BEFORE
+ "run body\n"
+ module.RUN_CLOSE_BEFORE
+ module.EVENT_CALLBACK_SIGNATURE_BEFORE
+ "callback docstring and push helper\n"
+ module.EVENT_CALLBACK_BODY_BEFORE
+ "tool start body\n"
+ module.EVENT_CALLBACK_END_BEFORE
+ module.EVENT_CALLBACK_CALL_BEFORE
+ "suffix\n",
encoding="utf-8",
)
@ -1059,6 +1069,48 @@ def test_api_session_patch_accepts_parent_lineage(tmp_path: Path):
assert "session_parent_conflict" in patched
assert "db.reopen_session(session_id)" in patched
assert 'db.end_session(session_id, f"api_run_{terminal_status}")' in patched
assert "def _record_run_activity(" in patched
assert '"_thinking": "Hermes is reasoning"' in patched
assert '"run.started": "Worker started"' in patched
assert '"run.completed": "Worker completed"' in patched
assert '"reasoning.available": "Hermes finished a reasoning step"' in patched
assert '"subagent.progress": "Nested worker progress"' in patched
assert "redact_sensitive_text" in patched
assert 'getattr(os, "O_NOFOLLOW", 0)' in patched
assert "os.fchmod(fd, 0o600)" in patched
assert "session_id=session_id" in patched
assert 'self._record_run_activity(session_id, "run.started")' in patched
assert 'detail = tool_name if event_type in {' in patched
assert 'if event_type == "subagent.tool"' in patched
def test_web_session_activity_patch_projects_bounded_events(tmp_path: Path):
"""The DOM transcript includes events without polluting agent history."""
module_path = HERMES / "scripts" / "patch_web_session_activity.py"
spec = importlib.util.spec_from_file_location("patch_web_activity", module_path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
source = tmp_path / "web_server.py"
destination = tmp_path / "patched.py"
source.write_text(
"prefix\n"
+ module.HELPER_MARKER
+ " db = object()\n"
+ module.MESSAGES_BEFORE
+ "suffix\n",
encoding="utf-8",
)
module.patch(source, destination)
patched = destination.read_text(encoding="utf-8")
assert "def _run_activity_messages(" in patched
assert 'root = get_hermes_home() / "run-activity"' in patched
assert "path.stat().st_size > 600_000" in patched
assert "entries[-1_000:]" in patched
assert "*_run_activity_messages(sid)" in patched
assert "messages.sort(" in patched
def test_legacy_api_sessions_are_nested_idempotently(tmp_path: Path):
@ -1723,40 +1775,41 @@ def test_local_flux_uses_low_vram_offload_without_reducing_resolution():
assert '"square": (1024, 1024)' in source
def test_chat_auth_file_mount_survives_atomic_provider_refresh():
def test_chat_auth_and_relay_are_pod_lifetime_only():
statefulset = _documents(HERMES / "chat-statefulset.yaml")[0]
pod = statefulset["spec"]["template"]["spec"]
containers = statefulset["spec"]["template"]["spec"]["containers"]
for name in ("hermes", "webui"):
container = next(item for item in containers if item["name"] == name)
env = {item["name"]: item["value"] for item in container["env"]}
assert env["HERMES_AUTH_FILE"] == "/shared-auth/auth.json"
assert env["HERMES_AUTH_FILE"] == "/runtime-access/hermes-auth.json"
mount = next(
item for item in container["volumeMounts"] if item["name"] == "provider-auth"
item for item in container["volumeMounts"] if item["name"] == "runtime-access"
)
assert mount["mountPath"] == "/shared-auth"
assert mount["mountPath"] == "/runtime-access"
assert "subPath" not in mount
hermes_env = {
item["name"]: item["value"]
for item in next(item for item in containers if item["name"] == "hermes")["env"]
}
hermes_mount = next(
item
for item in next(
item for item in containers if item["name"] == "hermes"
)["volumeMounts"]
if item["name"] == "provider-auth"
)
assert hermes_mount.get("readOnly") is not True
webui_mount = next(
item
for item in next(
item for item in containers if item["name"] == "webui"
)["volumeMounts"]
if item["name"] == "provider-auth"
)
assert webui_mount["readOnly"] is True
runtime = next(item for item in pod["volumes"] if item["name"] == "runtime-access")
assert runtime["emptyDir"] == {"medium": "Memory", "sizeLimit": "2Mi"}
assert not any(item["name"] == "provider-auth" for item in pod["volumes"])
init_command = next(
item for item in pod["initContainers"] if item["name"] == "init-config"
)["command"][2]
for key in (
"ANTHROPIC_API_KEY",
"API_SERVER_KEY",
"CLAUDE_CODE_OAUTH_TOKEN",
"GITEA_TOKEN",
"HERMES_IMAGE_BROKER_KEY",
"OPENAI_API_KEY",
):
assert key in init_command
assert "printf 'API_SERVER_KEY=%s" not in init_command
assert hermes_env["AGENT_BROWSER_EXECUTABLE_PATH"].endswith("/chrome-linux/headless_shell")
assert "--no-sandbox" in hermes_env["AGENT_BROWSER_ARGS"]

View File

@ -802,16 +802,21 @@ def test_agent_refreshes_routes_after_restoring_cli_logins():
"hermes_coordinator.py --once"
)
env = {item["name"]: item["value"] for item in configure["env"]}
assert env["HERMES_AUTH_FILE"] == "/shared-auth/auth.json"
assert env["HERMES_AUTH_FILE"] == "/runtime-access/hermes-auth.json"
assert env["PYTHONPATH"] == "/opt/hermes"
for name in ("bootstrap-coordinator", "configure-agent-clients"):
route_env = {
item["name"]: item["value"]
for item in init_containers[name]["env"]
}
assert route_env["CODEX_HOME"] == "/opt/data/home/.codex"
assert route_env["CODEX_HOME"] == "/runtime-access/codex"
assert route_env["CLAUDE_CONFIG_DIR"] == "/runtime-access/claude"
assert "/opt/data/tools/bin" in route_env["PATH"]
assert "patch-web-session-activity" in init_containers
web_patch = init_containers["patch-web-session-activity"]
assert "/opt/coordinator/patch_web_session_activity.py" in web_patch["command"]
containers = {
item["name"]: item
for item in deployment["spec"]["template"]["spec"]["containers"]
@ -820,8 +825,18 @@ def test_agent_refreshes_routes_after_restoring_cli_logins():
item["name"]: item["value"]
for item in containers["model-steward"]["env"]
}
assert steward_env["CODEX_HOME"] == "/opt/data/home/.codex"
assert steward_env["CODEX_HOME"] == "/runtime-access/codex"
assert steward_env["CLAUDE_CONFIG_DIR"] == "/runtime-access/claude"
assert "/opt/data/tools/bin" in steward_env["PATH"]
hermes_mounts = {
(item["name"], item["mountPath"], item.get("subPath"))
for item in containers["hermes"]["volumeMounts"]
}
assert (
"web-server-patch",
"/opt/hermes/hermes_cli/web_server.py",
"web_server.py",
) in hermes_mounts
def test_flux_health_checks_follow_the_owner_oauth_sidecar():
@ -941,14 +956,17 @@ def test_owner_agent_has_pinned_dedicated_node_ssh_access():
if item["name"] == "init-config"
)
command = init["command"][2]
assert "cp /vault/secrets/node-ssh-config /opt/data/home/.ssh/config" in command
assert "ln -s /runtime-access/node-ssh-config /opt/data/home/.ssh/config" in command
assert (
"cp /vault/secrets/node-ssh-known-hosts /opt/data/home/.ssh/known_hosts"
"ln -s /runtime-access/node-ssh-known-hosts /opt/data/home/.ssh/known_hosts"
in command
)
assert "ln -s home/.ssh /opt/data/.ssh" in command
assert "chmod 0700 /opt/data/home/.ssh" in command
assert "chmod 0600 /opt/data/home/.ssh/id_ed25519_atlas_nodes" in command
assert (
"ln -s /runtime-access/node-ssh-private-key "
"/opt/data/home/.ssh/id_ed25519_atlas_nodes"
) in command
config = yaml.safe_load((HERMES / "agent-configmap.yaml").read_text())["data"]
assert "ssh_config" not in config

View File

@ -207,6 +207,15 @@ def test_configure_routes_keeps_every_profile_on_switchyard(tmp_path: Path):
"GIT_ASKPASS=/opt/coordinator/gitea_askpass.sh\n",
encoding="utf-8",
)
stale_profile = tmp_path / "profiles/codex-high"
stale_profile.mkdir(parents=True)
(stale_profile / ".env").write_text(
"CLAUDE_CODE_OAUTH_TOKEN=stale-claude\n"
"GITEA_TOKEN=stale-gitea\n"
"HERMES_IMAGE_BROKER_KEY=stale-relay\n"
"USER_SETTING=preserve\n",
encoding="utf-8",
)
codex = routing.Catalog(
"openai-codex", ["gpt-5.6-sol", "gpt-5.6-terra"], True, True, "connected"
)
@ -253,9 +262,11 @@ def test_configure_routes_keeps_every_profile_on_switchyard(tmp_path: Path):
)
profile_env = (tmp_path / "profiles/codex-high/.env").read_text(encoding="utf-8")
assert "CLAUDE_CODE_OAUTH_TOKEN=claude-secret" in profile_env
assert "GITEA_TOKEN=gitea-secret" in profile_env
assert "CLAUDE_CODE_OAUTH_TOKEN" not in profile_env
assert "GITEA_TOKEN" not in profile_env
assert "HERMES_IMAGE_BROKER_KEY" not in profile_env
assert "API_SERVER_KEY" not in profile_env
assert "USER_SETTING=preserve" in profile_env
assert "claude-secret" not in json.dumps(routes)
assert (tmp_path / "profiles/codex-high/.env").stat().st_mode & 0o777 == 0o600
@ -411,10 +422,15 @@ def test_cassandra_sync_repairs_existing_worktree(
(workspace / ".git").mkdir(parents=True)
monkeypatch.setattr(coordinator, "CASSANDRA_BASE_PATH", workspace)
monkeypatch.setattr(coordinator.shutil, "which", lambda _name: "/usr/bin/git")
token_path = tmp_path / "gitea-token"
token_path.write_text("configured\n", encoding="utf-8")
monkeypatch.setenv("HERMES_GITEA_TOKEN_FILE", str(token_path))
commands: list[list[str]] = []
environments: list[dict[str, str]] = []
def run(command, **_kwargs):
def run(command, **kwargs):
commands.append(command)
environments.append(kwargs.get("env", {}))
if command[-2:] == ["get-url", "origin"]:
return subprocess.CompletedProcess(
command, 0 if current_remote else 2, stdout=f"{current_remote or ''}\n"
@ -422,10 +438,11 @@ def test_cassandra_sync_repairs_existing_worktree(
return subprocess.CompletedProcess(command, 0)
monkeypatch.setattr(coordinator.subprocess, "run", run)
assert coordinator.sync_cassandra_repo({"GITEA_TOKEN": "configured"}) == "ready"
assert coordinator.sync_cassandra_repo({"GITEA_TOKEN": "must-not-pass"}) == "ready"
repair_commands = [command for command in commands if repair_action in command]
assert bool(repair_commands) is bool(repair_action)
assert commands[-1][-4:] == ["fetch", "--quiet", "--prune", "origin"]
assert all("GITEA_TOKEN" not in environment for environment in environments)
def test_cassandra_sync_repairs_origin_without_token(tmp_path: Path, monkeypatch):

View File

@ -0,0 +1,276 @@
"""Contracts for Hermes' Vault-only runtime access boundary."""
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
import yaml
ROOT = Path(__file__).parents[2]
HERMES = ROOT / "services" / "hermes"
SCRIPTS = HERMES / "scripts"
def _load(name: str):
spec = importlib.util.spec_from_file_location(name, SCRIPTS / f"{name}.py")
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def test_agent_runtime_stage_keeps_credentials_in_memory(tmp_path: Path, monkeypatch):
stage = _load("stage_runtime_access")
vault = tmp_path / "vault"
runtime = tmp_path / "runtime"
home = tmp_path / "home"
vault.mkdir()
(home / ".claude").mkdir(parents=True)
(home / ".claude" / "backups").mkdir()
(home / ".codex" / "skills").mkdir(parents=True)
(home / ".codex" / "sessions").mkdir()
(home / ".claude" / "settings.json").write_text("{}\n", encoding="utf-8")
(home / ".claude" / "backups" / "credentials.json").write_text(
"do-not-link\n", encoding="utf-8"
)
values = {
"agent-api-key": "agent-key",
"chat-relay-key": "relay-key",
"gitea-token": "gitea-key",
"gitea-username": "hermes-automation",
"node-ssh-private-key": "private-key",
"node-ssh-config": "host-config",
"node-ssh-known-hosts": "known-hosts",
"claude-credentials": json.dumps(
{"claudeAiOauth": {"refreshToken": "claude-refresh"}}
),
"codex-auth": json.dumps({"tokens": {"refresh_token": "codex-refresh"}}),
}
for name, value in values.items():
(vault / name).write_text(value + "\n", encoding="utf-8")
monkeypatch.setattr(stage, "VAULT_ROOT", vault)
monkeypatch.setattr(stage, "RUNTIME_ROOT", runtime)
monkeypatch.setattr(stage, "PERSISTENT_HOME", home)
monkeypatch.setattr(stage.os, "chown", lambda *_args: None)
stage.stage_agent()
assert (runtime / "claude/.credentials.json").stat().st_mode & 0o777 == 0o600
assert (runtime / "codex/auth.json").stat().st_mode & 0o777 == 0o600
assert (runtime / "claude/settings.json").is_symlink()
assert (runtime / "codex/skills").is_symlink()
assert not (runtime / "claude/backups").exists()
assert not (runtime / "codex/sessions").exists()
auth = json.loads((runtime / "hermes-auth.json").read_text(encoding="utf-8"))
assert auth == {"version": 1, "providers": {}, "credential_pool": {}}
def test_invalid_runtime_json_is_removed(tmp_path: Path, monkeypatch):
stage = _load("stage_runtime_access")
vault = tmp_path / "vault"
runtime = tmp_path / "runtime"
vault.mkdir()
runtime.mkdir()
(vault / "credential").write_text("not-json\n", encoding="utf-8")
monkeypatch.setattr(stage, "VAULT_ROOT", vault)
monkeypatch.setattr(stage.os, "chown", lambda *_args: None)
destination = runtime / "credential.json"
try:
stage._validated_json("credential", destination, ("token",))
except json.JSONDecodeError:
pass
else:
raise AssertionError("invalid credential JSON should fail staging")
assert not destination.exists()
def test_runtime_refresh_sync_uses_cas_and_preserves_other_fields(
tmp_path: Path, monkeypatch
):
sync = _load("sync_runtime_credentials")
claude = tmp_path / "claude.json"
codex = tmp_path / "codex.json"
claude.write_text(
json.dumps({"claudeAiOauth": {"refreshToken": "new-claude"}}),
encoding="utf-8",
)
codex.write_text(
json.dumps({"tokens": {"refresh_token": "new-codex"}}),
encoding="utf-8",
)
monkeypatch.setattr(
sync,
"CREDENTIALS",
{
"claude_credentials_json": (
claude,
("claudeAiOauth", "refreshToken"),
),
"codex_auth_json": (codex, ("tokens", "refresh_token")),
},
)
writes = []
def request(method, path, payload=None, *, token=""):
assert token == "vault-token"
if method == "GET":
return {
"data": {
"data": {
"claude_credentials_json": "old",
"codex_auth_json": "old",
"agent_api_key": "preserve-me",
},
"metadata": {"version": 7},
}
}
writes.append((path, payload))
return {}
monkeypatch.setattr(sync, "_request", request)
assert sync.sync_once("vault-token") == [
"claude_credentials_json",
"codex_auth_json",
]
assert writes[0][1]["options"] == {"cas": 7}
assert writes[0][1]["data"]["agent_api_key"] == "preserve-me"
def test_subprocess_patches_strip_and_redact_runtime_credentials(tmp_path: Path):
boundary = _load("patch_subprocess_secret_boundary")
process = _load("patch_process_output_redaction")
local_source = tmp_path / "local.py"
local_output = tmp_path / "patched-local.py"
local_source.write_text("prefix\n" + boundary.BEFORE + "suffix\n", encoding="utf-8")
boundary.patch(local_source, local_output)
patched_local = local_output.read_text(encoding="utf-8")
for name in (
"API_SERVER_KEY",
"CLAUDE_CODE_OAUTH_TOKEN",
"GITEA_TOKEN",
"HERMES_IMAGE_BROKER_KEY",
):
assert f'"{name}"' in patched_local
process_source = tmp_path / "process.py"
process_output = tmp_path / "patched-process.py"
process_source.write_text(
"prefix\n" + process.BEFORE + "suffix\n", encoding="utf-8"
)
process.patch(process_source, process_output)
assert "code_file=False" in process_output.read_text(encoding="utf-8")
def test_manifests_never_seed_access_material_into_persistent_env():
agent = yaml.safe_load((HERMES / "agent-deployment.yaml").read_text())
chat = yaml.safe_load((HERMES / "chat-statefulset.yaml").read_text())
triage = yaml.safe_load((HERMES / "deployment.yaml").read_text())
for workload in (agent, chat, triage):
pod = workload["spec"]["template"]["spec"]
runtime = next(item for item in pod["volumes"] if item["name"] == "runtime-access")
assert runtime["emptyDir"]["medium"] == "Memory"
assert not any(item["name"] == "provider-auth" for item in pod["volumes"])
init = next(item for item in pod["initContainers"] if item["name"] == "init-config")
command = init["command"][2]
for key in (
"ANTHROPIC_API_KEY",
"API_SERVER_KEY",
"CLAUDE_API_KEY",
"CLAUDE_CODE_OAUTH_TOKEN",
"GITEA_TOKEN",
"HERMES_IMAGE_BROKER_KEY",
"OPENAI_API_KEY",
):
assert f"printf '{key}=" not in command
assert f"upsert_env {key}" not in command
annotations = agent["spec"]["template"]["metadata"]["annotations"]
agent_runtime = next(
item
for item in agent["spec"]["template"]["spec"]["volumes"]
if item["name"] == "runtime-access"
)
assert agent_runtime["emptyDir"] == {
"medium": "Memory",
"sizeLimit": "128Mi",
}
assert "vault.hashicorp.com/agent-inject-secret-anthropic-token" not in annotations
assert annotations["vault.hashicorp.com/agent-inject-secret-claude-credentials"] == (
"kv/data/atlas/hermes/agent-tokens"
)
assert annotations["vault.hashicorp.com/agent-inject-secret-codex-auth"] == (
"kv/data/atlas/hermes/agent-tokens"
)
credential_sync = next(
item
for item in agent["spec"]["template"]["spec"]["containers"]
if item["name"] == "credential-sync"
)
assert {
(item["mountPath"], item.get("subPath"))
for item in credential_sync["volumeMounts"]
if item["name"] == "runtime-access"
} == {
("/runtime-access/claude", "claude"),
("/runtime-access/codex", "codex"),
}
triage_annotations = triage["spec"]["template"]["metadata"]["annotations"]
assert "vault.hashicorp.com/agent-inject-secret-anthropic-token" not in (
triage_annotations
)
assert triage_annotations[
"vault.hashicorp.com/agent-inject-secret-triage-api-key"
] == "kv/data/atlas/hermes/triage-api"
askpass = (SCRIPTS / "gitea_askpass.sh").read_text(encoding="utf-8")
assert "/runtime-access/gitea-token" in askpass
assert "GITEA_TOKEN" not in askpass
def test_chat_media_reads_one_raw_runtime_secret(tmp_path: Path):
module = _load("telegram_media_server")
secret = tmp_path / "relay"
secret.write_text("relay-value\n", encoding="utf-8")
assert module.read_relay_key(secret) == "relay-value"
def test_ariadne_receives_the_triage_key_directly_from_vault():
maintenance = ROOT / "services" / "maintenance" / "apps" / "ariadne-deployment.yaml"
deployment = yaml.safe_load(maintenance.read_text(encoding="utf-8"))
annotations = deployment["spec"]["template"]["metadata"]["annotations"]
template = annotations["vault.hashicorp.com/agent-inject-template-ariadne-env.sh"]
assert 'secret "kv/data/atlas/hermes/triage-api"' in template
assert 'export ARIADNE_HERMES_API_KEY="{{ .Data.data.api_key }}"' in template
assert 'secret "kv/data/atlas/hermes/developer-gitea"' in template
assert 'export ARIADNE_HERMES_GITEA_TOKEN="{{ .Data.data.token }}"' in template
container = deployment["spec"]["template"]["spec"]["containers"][0]
assert not any(item["name"] == "ARIADNE_HERMES_API_KEY" for item in container["env"])
assert not any(
item["name"] == "ARIADNE_HERMES_GITEA_TOKEN" for item in container["env"]
)
vault_policy = (
ROOT / "services" / "vault" / "scripts" / "vault_k8s_auth_configure.sh"
).read_text(encoding="utf-8")
assert '"hermes/triage-oidc hermes/agent-tokens hermes/triage-api"' in vault_policy
assert 'hermes-credential-sync' in vault_policy
assert 'hermes/triage-api hermes/developer-gitea' in vault_policy
def test_gitea_bootstrap_reuses_valid_vault_tokens():
bootstrap = (
ROOT / "services" / "gitea" / "scripts" / "gitea_atlas_identity_ensure.sh"
).read_text(encoding="utf-8")
assert "vault_login\n\nreconciler_token=$(vault_read_field" in bootstrap
assert 'if ! token_is_valid "${reconciler_user}"' in bootstrap
assert 'if ! token_is_valid "${hermes_user}"' in bootstrap
assert bootstrap.count('generate_token "${reconciler_user}"') == 1
assert bootstrap.count('generate_token "${hermes_user}"') == 1