hermes: isolate runtime access and expose activity
This commit is contained in:
parent
d29a5737fe
commit
89b9f1a4c7
@ -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
|
||||
|
||||
6
dockerfiles/Dockerfile.hermes-agent.dockerignore
Normal file
6
dockerfiles/Dockerfile.hermes-agent.dockerignore
Normal 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
|
||||
@ -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;
|
||||
|
||||
@ -3,6 +3,6 @@
|
||||
"helmrelease_host_hints": 23,
|
||||
"http_endpoints": 61,
|
||||
"services": 91,
|
||||
"workloads": 124
|
||||
"workloads": 125
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@ -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
@ -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`).
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -3,6 +3,6 @@
|
||||
"helmrelease_host_hints": 23,
|
||||
"http_endpoints": 61,
|
||||
"services": 91,
|
||||
"workloads": 124
|
||||
"workloads": 125
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@ -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
@ -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`).
|
||||
|
||||
@ -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}\"}}"
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 ""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" \
|
||||
"$@"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
|
||||
39
services/hermes/scripts/patch_process_output_redaction.py
Normal file
39
services/hermes/scripts/patch_process_output_redaction.py
Normal 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())
|
||||
45
services/hermes/scripts/patch_subprocess_secret_boundary.py
Normal file
45
services/hermes/scripts/patch_subprocess_secret_boundary.py
Normal 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())
|
||||
96
services/hermes/scripts/patch_web_session_activity.py
Normal file
96
services/hermes/scripts/patch_web_session_activity.py
Normal 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())
|
||||
162
services/hermes/scripts/stage_runtime_access.py
Normal file
162
services/hermes/scripts/stage_runtime_access.py
Normal 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())
|
||||
141
services/hermes/scripts/sync_runtime_credentials.py
Normal file
141
services/hermes/scripts/sync_runtime_credentials.py
Normal 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())
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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"]
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
276
testing/tests/test_hermes_runtime_access.py
Normal file
276
testing/tests/test_hermes_runtime_access.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user