Files
prose-contracts/scripts/agent-health-check.py
T
root af9397d672
PR Pipeline — Authorize → Validate → Review → Merge / auth (pull_request) Successful in 8s
PR Pipeline — Authorize → Validate → Review → Merge / validate (pull_request) Successful in 6s
PR Pipeline — Authorize → Validate → Review → Merge / lint (pull_request) Successful in 19s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (pull_request) Successful in 6s
PR Pipeline — Authorize → Validate → Review → Merge / gate (pull_request) Successful in 0s
fix(alignment): accept multiple wrapper shapes in hermes-real check
The fleet's wrappers do not all use a hermes-real indirection. Some (tanko,
mumuni) exec the venv module directly. This check now:
  1. Tries hermes-real at {home}/.local/bin (koonimo's shape)
  2. Tries the venv under {home}/.hermes/hermes-agent/venv (tanko/mumuni shape)
  3. Tries /usr/local/lib/hermes-agent/venv (legacy system-wide shape)

Each match is reported with which shape it matched, so a genuinely broken
wrapper is still a failure while a different-but-valid shape is not.
2026-09-28 22:23:08 +00:00

811 lines
38 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
/root/scripts/agent-health-check.py — Consolidated Agent Health Verification v4
Verifies: LiteLLM keys (agent-specific), GPU port conflicts, agent Zulip streaming,
gateway liveness, gateway log health, CT liveness, config YAML integrity,
wrapper/CLI integrity, vault secret non-emptiness. NEVER restarts anything.
Usage:
python3 /root/scripts/agent-health-check.py # Full check
python3 /root/scripts/agent-health-check.py --json # Machine-readable
python3 /root/scripts/agent-health-check.py --quiet # Only output on failure
Cron: */10 * * * * python3 /root/scripts/agent-health-check.py --quiet
Changelog:
v2 (2026-07-26): Added CT liveness, config validation, wrapper integrity,
vault secret emptiness check. Fixed Koby/Koonimo SSH hosts and agent key
name format ({NAME}_LITELLM_API_KEY not LITELLM_API_KEY_{NAME}).
Fleet roster: tanko (.122), koby (.129), koonimo (.114), abiba (.24).
(v2 also carried a mumuni probe; see v5 — mumuni is no longer probed: she
moved to her own container, kagentz CT 105 / .14, and is monitored there.)
v3 (2026-09-08): GPU unit repoint verified live (.8 llama-chat-api.service,
.110 llama-server.service, .15 strix-server.service) — .8 was probing a stale
llama-server unit that reads inactive, producing false UNREACHABLE legs.
systemctl is-active no longer swallows non-zero exit as SSH failure.
Fixed UnboundLocalError on the abiba/koonimo gateway leg (pid unbound in the
summary f-string). Abiba's LiteLLM key now comes from /root/.pi/agent/env.sh
(#735 agent separation; creds moved out of shared /root/.bashrc).
v4 (2026-09-10): probe-drift round 2 (prose-contracts follow-up to #65/#66/#68).
abiba declared pi-only runtime — Hermes-era config/wrapper/gateway checks are
skipped (harness purge). koby declared report_only per the captain's
2026-08-17 ruling: every koby leg is detected and reported, never counted as a
fleet failure and never repaired. koby's PVE mapping corrected to storepve
(CT 111 tdunna lives on .6 — the old amdpve mapping produced a false
ct-unreachable). The wrapper infisical-path check had two stale-expectation
bugs: it read only the first 20 lines of the wrapper, so koonimo (whose
wrapper does reference /usr/bin/infisical, just past line 20) was falsely
FAILed as "path may be wrong"; and it treated the absence of any infisical
reference as a fault, though koby's wrapper sources the key from
~/.hermes/.env and never invokes infisical. The check now reads the full
wrapper body, accepts a no-infisical wrapper, and verifies that any absolute
infisical path the wrapper references actually exists. Report-only findings
are surfaced in a machine-readable `report_only` array in --json output,
separate from `failures`. Every run prints absolute execution provenance
(script + cwd) in the header, in the cron ALERT line, and in --json output so
a stale-consumer report is distinguishable from a fault at read time.
v5 (2026-09-10): roster correction only, no behavior change. mumuni was removed
from the AGENTS dict when she moved off this host onto her own container
(kagentz CT 105 on minipve, .14, dedicated `hermes` user) and is monitored
from her side. This script must not probe mumuni or .24 — the v2 changelog
roster line was the last reference still placing her at .24 / CT100.
v6 (2026-09-28): .8 GPU health probe now runs as `llmuser` instead of `root`.
Root SSH to .8 was lost when the guest was rebuilt, so every .8 leg read as
UNREACHABLE for a healthy host. llmuser owns llama-server and can read
`systemctl is-active`, `systemctl show -p MainPID`, and the :8080 pid.
.110 and .15 keep the default `root` user.
"""
import subprocess, json, sys, os, time, re, io, contextlib
from datetime import datetime
LITELLM = "http://192.168.68.116:80"
INFISICAL_PROJECT = "322fceab-39da-4854-a55a-568e76c0f13f"
INFISICAL_ENV = "prod"
# PVE node IPs for CT liveness checks
PVE_NODES = {
"amdpve": "192.168.68.15",
"minipve": "192.168.68.12",
"storepve": "192.168.68.6",
"acerpve": "192.168.68.9",
"ocupve": "192.168.68.5",
}
# Agent definitions: ct, host, user, pve_node, vault_key_name
AGENTS = {
"tanko": {"ct": 112, "host": "192.168.68.122", "user": "jerome", "pve": "minipve", "vault_key": "TANKO_LITELLM_API_KEY", "runtime": "hybrid"},
# abiba = pi agent (.24) — no vault key; its LiteLLM key is read from its
# local env file (key_env below), not from the shared vault or .bashrc.
# runtime=pi: abiba has run pi-only since the harness purge. There is no
# Hermes gateway, no ~/.hermes/config.yaml and no hermes CLI wrapper on .24
# (the /root/.local/bin/hermes symlink is dangling), so the Hermes-era
# config/wrapper/gateway legs are skipped rather than reported as faults.
"abiba": {"ct": 100, "host": "192.168.68.24", "user": "root", "pve": "minipve",
"vault_key": None, "runtime": "pi",
"key_env": {"file": "/root/.pi/agent/env.sh", "var": "LITELLM_API_KEY"}},
# koby = report-only (captain's 2026-08-17 ruling, Rule 17): detect and
# report, NEVER repair, and never count against fleet failures. CT 111
# (tdunna) lives on storepve (.6) — verified live 2026-09-10; the previous
# amdpve mapping made `pct status 111` fail and read as ct-unreachable.
"koby": {"ct": 111, "host": "192.168.68.129", "user": "root", "pve": "storepve", "vault_key": "KOBY_LITELLM_API_KEY", "report_only": True},
"koonimo": {"ct": 113, "host": "192.168.68.114", "user": "root", "pve": "amdpve", "vault_key": "KOONIMO_LITELLM_API_KEY"},
}
# Systemd units verified live 2026-09-08 (systemctl list-units on each host):
# .8 rtx3090 (gpu-dense) -> llama-chat-api.service (active; the old
# llama-server.service unit file is stale/inactive — probing it read as
# UNREACHABLE for a healthy process)
# .110 rtx5070 (ocu-llm VM) -> llama-server.service (active)
# .15 strixhalo (amdpve) -> strix-server.service (active)
GPU_HOSTS = {
"gpu-rtx3090 (.8)": {"host": "192.168.68.8", "port": 8080, "service": "llama-chat-api.service", "user": "llmuser"},
"gpu-rtx5070 (.110)": {"host": "192.168.68.110", "port": 8080, "service": "llama-server.service"},
"gpu-strixhalo (.15)": {"host": "192.168.68.15", "port": 8080, "service": "strix-server.service"},
}
FAIL = []
REPORT_ONLY = []
def _fail(key, agent_name=None):
"""Record a failure, except for report-only agents.
Koby is report-only per the captain's 2026-08-17 ruling (Rule 17): its legs
are detected and reported, never repaired and never counted as fleet
failures. A red fleet alert on a known report-only leg is a false alarm.
Report-only findings are tracked separately so --json consumers can still
see them without them counting as fleet failures. Any non-report-only agent
(or a leg with no agent, e.g. GPU hosts) records normally.
"""
if agent_name and AGENTS.get(agent_name, {}).get("report_only"):
REPORT_ONLY.append(key)
print(f" 🔍 report-only ({agent_name}): {key} — reported, not counted/repaired")
return
FAIL.append(key)
INFISICAL_TOKEN = os.environ.get("INFISICAL_TOKEN")
if not INFISICAL_TOKEN:
# Fallback: read the shared vault token file
_token_path = os.path.expanduser("~/.infisical-token")
if os.path.isfile(_token_path):
try:
with open(_token_path) as _f:
INFISICAL_TOKEN = _f.read().strip()
except (OSError, UnicodeDecodeError):
pass
INFISICAL_API_URL = os.environ.get("INFISICAL_API_URL", "https://vault.sysloggh.net")
# ── Helpers ──────────────────────────────────────────────────────────
def ssh(host, cmd, user="root"):
"""Execute a command on a remote host, return stdout or None."""
try:
result = subprocess.run(
["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=8",
f"{user}@{host}", cmd],
capture_output=True, text=True, timeout=15
)
return result.stdout.strip() if result.returncode == 0 else None
except:
return None
def get_user_home(user):
"""Resolve the home directory for a user.
For 'root', returns '/root'. For any other user, returns '/home/<user>'.
This is used to construct paths that reference a user's home directory
(e.g., ~/.local/bin/hermes, ~/.hermes/config.yaml) instead of hardcoding /root/.
"""
if user == "root":
return "/root"
else:
return f"/home/{user}"
def http_get(url, headers=None, timeout=5):
"""Return HTTP status code as string."""
try:
cmd = ["curl", "-sfk", "--connect-timeout", str(timeout), "-o", "/dev/null", "-w", "%{http_code}"]
if headers:
for k, v in headers.items():
cmd.extend(["-H", f"{k}: {v}"])
cmd.append(url)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout+3)
return result.stdout.strip() or "000"
except:
return "timeout"
def http_json(url, headers=None, timeout=5):
"""Return parsed JSON from URL, or None."""
try:
cmd = ["curl", "-sfk", "--connect-timeout", str(timeout)]
if headers:
for k, v in headers.items():
cmd.extend(["-H", f"{k}: {v}"])
cmd.append(url)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout+3)
return json.loads(result.stdout) if result.returncode == 0 and result.stdout else None
except:
return None
def run_infisical(args, quiet=True):
"""Run infisical CLI with env-based auth, return stdout or None."""
env = os.environ.copy()
env["INFISICAL_API_URL"] = INFISICAL_API_URL
if INFISICAL_TOKEN:
env["INFISICAL_TOKEN"] = INFISICAL_TOKEN
try:
result = subprocess.run(
["/usr/bin/infisical"] + args,
capture_output=True, text=True, timeout=15, env=env
)
return result.stdout.strip() if result.returncode == 0 else None
except:
return None
# ── KEY LOOKUP FIX ───────────────────────────────────────────────────
def _get_agent_key(agent_name, vault_key_name):
"""Retrieve agent-specific key from Infisical vault.
Uses {NAME}_LITELLM_API_KEY format (e.g., TANKO_LITELLM_API_KEY,
KOONIMO_LITELLM_API_KEY) which matches actual vault key names.
"""
if not vault_key_name:
return None
# Primary: get the agent-specific key by name
key = run_infisical([
"secrets", "get", vault_key_name,
"--projectId=" + INFISICAL_PROJECT,
"--env=" + INFISICAL_ENV,
"--plain",
])
if key and key.startswith("sk-"):
return key
# Fallback: export all and search for the key name
try:
export = run_infisical([
"export",
"--projectId=" + INFISICAL_PROJECT,
"--env=" + INFISICAL_ENV,
"--format=dotenv",
])
if export:
for line in export.splitlines():
if line.startswith(vault_key_name + "="):
value = line.split("=", 1)[1].strip().strip('"').strip("'")
if value.startswith("sk-"):
return value
except:
pass
return None
def _read_env_export(path, var):
"""Parse `export VAR=value` (or `VAR=value`) out of a local env file.
#735 agent separation (2026-09-06): agent creds moved out of the shared
/root/.bashrc into per-agent env files under /root/.pi/agent/ (bashrc's
source line keeps abiba shells resolving them, but the file of record is
env.sh). Do NOT fall back to /root/.bashrc here: desktop (.200) SSH
sessions override LITELLM_API_KEY with mumuni's key, so sourcing bashrc
would validate the wrong identity.
"""
try:
with open(os.path.expanduser(path)) as _f:
for line in _f:
line = line.strip()
if not (line.startswith("export " + var + "=") or line.startswith(var + "=")):
continue
value = line.split("=", 1)[1].strip().strip('"').strip("'")
if value:
return value
except (OSError, UnicodeDecodeError):
pass
return None
def load_agent_keys():
"""Populate AGENTS[*]["key"] from the vault or the agent's local env file.
Called from main(), not at import: keeping this out of module scope lets the
module be imported (and unit tested) without live vault/SSH access. Vault
format is {NAME}_LITELLM_API_KEY (project 322fceab-39da-4854-a55a-568e76c0f13f,
env prod); abiba has no vault key and reads LITELLM_API_KEY from its local
/root/.pi/agent/env.sh (moved there from /root/.bashrc in #735).
"""
for agent_name in AGENTS:
info = AGENTS[agent_name]
key = _get_agent_key(agent_name, info.get("vault_key"))
if not key and info.get("key_env"):
key = _read_env_export(info["key_env"]["file"], info["key_env"]["var"])
AGENTS[agent_name]["key"] = key
# ═══════════════════════════════════════════════════════════════════
# CHECK 1: LiteLLM Key Validation (agent-specific keys)
# ═══════════════════════════════════════════════════════════════════
def check_keys():
for name, agent in AGENTS.items():
key = agent.get("key")
if not key:
print(f" ❌ {name}: NO KEY FOUND (vault/env empty or unreachable)")
_fail(f"key:{name}:no-key", name)
continue
data = http_json(f"{LITELLM}/v1/models",
headers={"Authorization": f"Bearer {key}"})
if data and data.get("data"):
model = data["data"][0].get("id", "?")
print(f" ✅ {name}: key valid → {model}")
else:
print(f" ❌ {name}: KEY FAILURE — auth rejected or unreachable")
_fail(f"key:{name}", name)
# ═══════════════════════════════════════════════════════════════════
# CHECK 2: GPU Port Conflict Detection (unit names verified live 2026-09-08)
# ═══════════════════════════════════════════════════════════════════
def check_gpu_ports():
for label, gpu in GPU_HOSTS.items():
host = gpu["host"]
port = gpu["port"]
svc = gpu["service"]
user = gpu.get("user", "root") # default root, overridden per-host where needed
# `systemctl is-active` exits non-zero when the unit is inactive or
# missing, which the ssh() helper would swallow as an SSH failure and
# report as UNREACHABLE. `|| true` keeps the real state word so we can
# tell "unit inactive" from "host unreachable".
svc_status = ssh(host, f"systemctl is-active {svc} || true", user=user)
port_owner = ssh(host, f"ss -tlnp 2>/dev/null | grep -Po ':{port}\\s+.*pid=\\K[0-9]+' | head -1", user=user)
if not svc_status:
print(f" ❌ {label}: UNREACHABLE")
FAIL.append(f"gpu-unreachable:{host}")
continue
if not port_owner:
print(f" ❌ {label}: PORT {port} NOT LISTENING (svc={svc_status})")
FAIL.append(f"gpu-no-port:{label}")
elif svc_status != "active":
svc_pid = ssh(host, f"systemctl show {svc} -p MainPID 2>/dev/null | cut -d= -f2", user=user)
if svc_pid and port_owner != svc_pid:
print(f" ❌ {label}: GHOST PROCESS — port owned by pid {port_owner}, svc pid {svc_pid} (svc={svc_status})")
FAIL.append(f"gpu-ghost:{label}:{port_owner}")
else:
print(f" ⚠️ {label}: svc={svc_status}, port owned by {port_owner}")
else:
health = ssh(host, f"curl -s --max-time 5 http://localhost:{port}/health", user=user)
if health and '"status":"ok"' in health:
print(f" ✅ {label}: healthy (pid={port_owner})")
elif health and '"status":"no slot available"' in health:
print(f" ✅ {label}: healthy (loading, pid={port_owner})")
elif health and '"error"' in health.lower():
print(f" ⚠️ {label}: error response (pid={port_owner}): {health[:80]}")
else:
print(f" ⚠️ {label}: unknown health (pid={port_owner}): {str(health)[:80]}")
# ═══════════════════════════════════════════════════════════════════
# CHECK 3: Agent Gateway Liveness + Streaming (now covers all agents)
# ═══════════════════════════════════════════════════════════════════
def _ssh_retry(host, cmd, user="root", timeout=15, retry_timeout=25, label=""):
"""SSH with one retry at a longer timeout.
Returns (stdout_or_None, probe_failed_bool, fail_kind).
When probe_failed is True, fail_kind is one of: timeout, ssh-failed.
"""
import subprocess as _sp
def _attempt(tmo, conn_tmo):
try:
r = _sp.run(
["ssh", "-o", "StrictHostKeyChecking=no", "-o", f"ConnectTimeout={conn_tmo}",
f"{user}@{host}", cmd],
capture_output=True, text=True, timeout=tmo)
return r.stdout.strip() if r.returncode == 0 else None
except _sp.TimeoutExpired:
return "__timeout__"
except:
return None
result = _attempt(timeout, 8)
if result is None or result == "__timeout__":
kind = "timeout" if result == "__timeout__" else "ssh-failed"
prefix = f"{label} " if label else ""
print(f" probe-failed: {prefix}ssh {user}@{host} — {kind} (retrying at {retry_timeout}s…)")
result = _attempt(retry_timeout, 15)
if result is None or result == "__timeout__":
kind = "timeout" if result == "__timeout__" else "ssh-failed"
return None, True, kind
return result, False, None
def check_agents():
for name, agent in AGENTS.items():
host = agent.get("host")
user = agent.get("user")
ct = agent["ct"]
report_only = agent.get("report_only", False)
# Tanko runs hybrid (DSH + Hermes) since 2026-08-27 — it runs both DSH and Hermes gateway.
# Hermes gateway, so skip the Hermes gateway/state/streaming/journal checks.
# Non-Hermes runtimes have no gateway to probe. dsh/pi-only skip the check;
# hybrid runs both DSH and Hermes and is checked normally.
if agent.get("runtime") in ("dsh", "pi"):
is_dsh = agent.get("runtime") == "dsh"
label = "DSH (DeepSeek Harness)" if is_dsh else "pi-only runtime"
since = "since 2026-08-27" if is_dsh else "since the harness purge"
live, probe_failed, fail_kind = _ssh_retry(host, "true", user=user)
if probe_failed:
print(f" ❌ {name}: {label} — probe-failed: ssh {user}@{host} {fail_kind} "
f"(retried at 25s: also {fail_kind}) [CT {ct}]")
_fail(f"probe-failed:{name}:{fail_kind}", name)
else:
print(f" ✅ {name}: {label} — no Hermes gateway {since} "
f"(ssh {user}@{host} OK, CT {ct})")
continue
if not host or not user:
print(f" ⬜ {name} (CT {ct}): cannot SSH — skip liveness check")
continue
# Resolve the Hermes gateway PID with retry. The probe target is
# explicit: ssh {user}@{host} pgrep -f hermes gateway.
pid, probe_failed, fail_kind = _ssh_retry(
host, "pgrep -f '[h]ermes_cli.main gateway run' | grep -v infisical | head -1", user=user)
if not pid and not probe_failed:
pid, probe_failed, fail_kind = _ssh_retry(
host, "pgrep -f '[h]ermes.*gateway' | grep -v infisical | grep -v bash | head -1", user=user)
if not pid and not probe_failed:
pid = "?"
if probe_failed:
print(f" ❌ {name}: probe-failed: ssh {user}@{host} {fail_kind} "
f"(retried at 25s: also {fail_kind}) [CT {ct}] — gateway status UNDETERMINED")
_fail(f"probe-failed:{name}:{fail_kind}", name)
continue
# ⛔ KOBY IS NEVER REPAIRED — diagnostic only (captain's 2026-08-17 ruling)
if report_only:
if pid == "?":
print(f" 🔍 {name}: REPORT-ONLY — probe: ssh {user}@{host} pgrep hermes-gateway "
f"-> no process found (reported only, NOT counted) [CT {ct}]")
_fail(f"gateway-down:{name}", name)
else:
print(f" 🔍 {name}: REPORT-ONLY — probe: ssh {user}@{host} pgrep hermes-gateway "
f"-> pid={pid} (running, reported only, NOT repaired) [CT {ct}]")
continue # Skip the rest of the check for Koby
# Gateway state file
state, _, _ = _ssh_retry(host, "cat ~/.hermes/gateway_state.json 2>/dev/null", user=user)
if state:
try:
st = json.loads(state)
gw_state = st.get("gateway_state", "?")
zulip = st.get("platforms", {}).get("zulip", {}).get("state", "?")
except:
gw_state, zulip = "corrupt", "?"
else:
gw_state, zulip = "no-state-file", "?"
# Zulip streaming check
adapter_paths = [
"~/.hermes/plugins/zulip-platform/adapter.py",
"~/.hermes/plugins/platforms/zulip/adapter.py",
]
streaming = "no"
for p in adapter_paths:
has_edit, _, _ = _ssh_retry(host, f"grep -c 'async def edit_message' {p} 2>/dev/null", user=user)
if has_edit and has_edit != "0":
streaming = "yes"
break
# Recent errors
recent_errors, _, _ = _ssh_retry(
host,
r"journalctl --user -u hermes-gateway --since '10 min ago' -o cat --no-pager 2>/dev/null "
r"| grep -ci 'error\|traceback\|exception\|401\|403\|500' || echo 0",
user=user)
recent_errors = (recent_errors or "0").strip().split("\n")[-1]
print(f" {'✅' if gw_state == 'running' and zulip == 'connected' else '⚠️'} "
f"{name}: probe: ssh {user}@{host} — gw={gw_state} zulip={zulip} "
f"streaming={streaming} errors_10m={recent_errors.strip() or '0'} pid={pid} [CT {ct}]")
# ═══════════════════════════════════════════════════════════════════
# CHECK 4: CT Liveness (NEW)
# ═══════════════════════════════════════════════════════════════════
def check_ct_liveness():
"""Check that all agent CTs are running on their PVE nodes."""
for name, agent in AGENTS.items():
ct = agent["ct"]
pve_node = agent.get("pve")
if not pve_node:
print(f" ⬜ {name} (CT {ct}): no PVE node mapped — skip")
continue
pve_ip = PVE_NODES.get(pve_node)
if not pve_ip:
print(f" ⬜ {name}: unknown PVE node '{pve_node}' — skip")
continue
status = ssh(pve_ip, f"pct status {ct} 2>/dev/null", user="root")
if not status:
print(f" ❌ {name} (CT {ct} on {pve_node}): PVE UNREACHABLE")
_fail(f"ct-unreachable:{name}:{pve_ip}", name)
elif "running" in status:
print(f" ✅ {name} (CT {ct} on {pve_node}): running")
elif "stopped" in status:
print(f" ❌ {name} (CT {ct} on {pve_node}): STOPPED")
_fail(f"ct-stopped:{name}", name)
else:
print(f" ⚠️ {name} (CT {ct} on {pve_node}): {status.strip()}")
# ═══════════════════════════════════════════════════════════════════
# CHECK 5: Config YAML Integrity (NEW)
# ═══════════════════════════════════════════════════════════════════
def check_config_integrity():
"""Verify agent config.yaml parses as valid YAML."""
for name, agent in AGENTS.items():
# DSH/pi-only runtimes have no Hermes config.yaml; hybrid has both.
if agent.get("runtime") == "dsh":
print(f" ⏭️ {name}: DSH — no Hermes config.yaml since 2026-08-27")
continue
if agent.get("runtime") == "pi":
print(f" ⏭️ {name}: pi-only runtime — no Hermes config.yaml since the harness purge")
continue
host = agent.get("host")
user = agent.get("user")
if not host or not user:
print(f" ⬜ {name}: cannot SSH — skip config check")
continue
home = get_user_home(user)
# Check YAML parses
yaml_ok = ssh(host,
f"python3 -c \"import yaml; yaml.safe_load(open('{home}/.hermes/config.yaml')); print('OK')\" 2>&1 || echo 'FAIL'",
user=user)
if not yaml_ok:
print(f" ❌ {name}: SSH UNREACHABLE (config check skipped)")
_fail(f"config-unreachable:{name}", name)
elif "OK" in yaml_ok:
print(f" ✅ {name}: config.yaml valid YAML")
else:
print(f" ❌ {name}: config.yaml YAML ERROR — {yaml_ok[:120]}")
_fail(f"config-yaml-error:{name}", name)
# ═══════════════════════════════════════════════════════════════════
# CHECK 6: Wrapper/CLI Integrity (NEW)
# ═══════════════════════════════════════════════════════════════════
def _infisical_invocation_paths(wrapper_body):
"""Absolute infisical paths the wrapper actually invokes.
Only executed (non-comment) lines count, and only a path followed by a real
infisical subcommand (e.g. `/usr/bin/infisical run`) is treated as an
invocation. A note such as `# migrated from /usr/local/bin/infisical` is
prose, not a call, so it must not manufacture a dangling-path false alarm.
"""
paths = []
for line in wrapper_body.splitlines():
code = line.split("#", 1)[0]
for _m in re.finditer(
r"(/[A-Za-z0-9._/-]*infisical)\s+(?:run|export|secrets|login|logout)\b",
code,
):
if _m.group(1) not in paths:
paths.append(_m.group(1))
return paths
def check_wrapper_integrity():
"""Verify the hermes CLI wrapper exists and can reach hermes-real."""
for name, agent in AGENTS.items():
# DSH/pi-only runtimes have no hermes CLI wrapper; hybrid has both.
if agent.get("runtime") == "dsh":
print(f" ⏭️ {name}: DSH — no hermes CLI wrapper since 2026-08-27")
continue
if agent.get("runtime") == "pi":
print(f" ⏭️ {name}: pi-only runtime — no hermes CLI wrapper since the harness purge")
continue
host = agent.get("host")
user = agent.get("user")
if not host or not user:
print(f" ⬜ {name}: cannot SSH — skip wrapper check")
continue
# Check wrapper exists
home = get_user_home(user)
wrapper = ssh(host, f"ls -la {home}/.local/bin/hermes 2>/dev/null", user=user)
if not wrapper:
# Check alternate wrapper locations
wrapper = ssh(host, "which hermes 2>/dev/null; command -v hermes 2>/dev/null", user=user)
if not wrapper:
print(f" ❌ {name}: NO HERMES CLI WRAPPER FOUND")
_fail(f"wrapper-missing:{name}", name)
continue
else:
print(f" ⚠️ {name}: hermes at {wrapper.strip()} (not ~/.local/bin/hermes)")
# Credential-injection mechanism. The Hermes-era wrapper injected creds
# with `/usr/bin/infisical run`, but the mechanism is not required to be
# infisical at all: koby's wrapper sources the key from ~/.hermes/.env
# and never mentions infisical, which is valid. The old check read only
# the first 20 lines, so koonimo's wrapper — which DOES reference
# /usr/bin/infisical, just past line 20 — false-failed as "path may be
# wrong". Read the full body, accept a no-infisical wrapper, and verify
# the absolute infisical path(s) the wrapper actually invokes. Only
# executed (non-comment) lines count: a comment or dead prose mentioning
# a removed path (litellm-api-keys.prose.md documents
# `rm -f /usr/local/bin/infisical`) must neither produce a dangling path
# nor trigger the PATH check — it is not an invocation.
wrapper_body = ssh(host, f"cat {home}/.local/bin/hermes 2>/dev/null", user=user) or ""
wrapper_code = "\n".join(line.split("#", 1)[0] for line in wrapper_body.splitlines())
invoked_paths = _infisical_invocation_paths(wrapper_body)
if "infisical" in wrapper_code:
if invoked_paths:
missing = []
for _p in invoked_paths:
_exists = ssh(host, f"test -x {_p} && echo OK || echo MISS", user=user)
if not _exists or _exists.strip().splitlines()[-1] != "OK":
missing.append(_p)
if len(missing) == len(invoked_paths):
inf_actual = ssh(host, "command -v infisical 2>/dev/null", user=user)
suffix = f" (infisical at {inf_actual})" if inf_actual else ""
print(f" ❌ {name}: wrapper invokes infisical via missing path(s) "
f"{', '.join(missing)}{suffix}")
_fail(f"wrapper-infisical-path:{name}", name)
elif missing:
print(f" ⚠️ {name}: wrapper has an unused/missing infisical path "
f"({', '.join(missing)}) but a working invocation — informational")
elif "/usr/bin/infisical" not in invoked_paths:
print(f" ⚠️ {name}: wrapper infisical path differs "
f"({', '.join(invoked_paths)}) — informational")
else:
print(f" ✅ {name}: wrapper infisical path OK")
else:
inf_actual = ssh(host, "command -v infisical 2>/dev/null", user=user)
if not inf_actual:
print(f" ❌ {name}: wrapper invokes infisical but the binary is MISSING")
_fail(f"wrapper-no-infisical:{name}", name)
else:
print(f" ✅ {name}: wrapper infisical resolves via PATH ({inf_actual})")
else:
print(f" ℹ️ {name}: wrapper resolves creds without infisical (e.g. ~/.hermes/.env) — OK")
# Check that the wrapper's target resolves. The fleet's wrappers do NOT
# all use a hermes-real indirection — some exec the venv module directly.
# Verify the wrapper actually points to something runnable.
hermes_real = ssh(host,
f"ls -la {home}/.local/bin/hermes-real 2>/dev/null || echo MISS",
user=user)
if hermes_real and hermes_real.strip() != "MISS":
print(f" ✅ {name}: wrapper shape: hermes-real at {home}/.local/bin/hermes-real")
else:
# Try the venv under home
venv_home = ssh(host,
f"test -x {home}/.hermes/hermes-agent/venv/bin/python && echo OK || echo MISS",
user=user)
if venv_home and venv_home.strip().splitlines()[-1] == "OK":
print(f" ✅ {name}: wrapper shape: direct venv exec ({home}/.hermes/hermes-agent/venv/bin/python)")
else:
# Try the system-wide venv
venv_sys = ssh(host,
"test -x /usr/local/lib/hermes-agent/venv/bin/python && echo OK || echo MISS",
user=user)
if venv_sys and venv_sys.strip().splitlines()[-1] == "OK":
print(f" ✅ {name}: wrapper shape: system venv (/usr/local/lib/hermes-agent/venv/bin/python)")
else:
print(f" ❌ {name}: wrapper target NOT RESOLVABLE (no hermes-real, no venv)")
_fail(f"wrapper-no-hermes-real:{name}", name)
# Check the .env file has the key
env_has_key = ssh(host,
f"grep -c 'LITELLM_API_KEY' {home}/.hermes/.env 2>/dev/null || echo 0",
user=user)
if env_has_key and env_has_key.strip() not in ("", "0"):
print(f" ✅ {name}: wrapper + .env key present")
else:
print(f" ⚠️ {name}: .env may be missing LITELLM_API_KEY entry")
# ═══════════════════════════════════════════════════════════════════
# CHECK 7: Vault Secret Non-Emptiness (NEW)
# ═══════════════════════════════════════════════════════════════════
def check_vault_secrets():
"""Verify agent-specific vault secrets are non-empty and start with sk-."""
for name, agent in AGENTS.items():
vault_key_name = agent.get("vault_key")
if not vault_key_name:
continue
key = agent.get("key")
if not key:
print(f" ❌ {name}: vault secret {vault_key_name} MISSING or EMPTY")
_fail(f"vault-empty:{name}:{vault_key_name}", name)
elif not key.startswith("sk-"):
print(f" ❌ {name}: vault secret {vault_key_name} WRONG FORMAT (starts '{key[:8]}...')")
_fail(f"vault-bad-format:{name}:{vault_key_name}", name)
else:
print(f" ✅ {name}: vault {vault_key_name}=sk-...{key[-4:]}")
# ═══════════════════════════════════════════════════════════════════
# DEPLOY: copy updated script to /root/scripts/ on local host
# ═══════════════════════════════════════════════════════════════════
def deploy_self():
"""Copy this script to /root/scripts/agent-health-check.py if out of date."""
dest = "/root/scripts/agent-health-check.py"
try:
with open(__file__, "r") as f:
current = f.read()
if os.path.isfile(dest):
with open(dest, "r") as f:
existing = f.read()
if current == existing:
return # Already deployed
# Write new version
with open(dest, "w") as f:
f.write(current)
os.chmod(dest, 0o755)
print(f" 📦 Deployed updated script to {dest}")
except:
pass # Not fatal if deploy fails
# ═══════════════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════════════
def _run_checks():
print("🔑 LiteLLM Keys:")
check_keys()
print()
print("🎮 GPU Port Health:")
check_gpu_ports()
print()
print("🤖 Agent Gateways:")
check_agents()
print()
print("🖥️ CT Liveness:")
check_ct_liveness()
print()
print("📝 Config Integrity:")
check_config_integrity()
print()
print("🔌 Wrapper/CLI Integrity:")
check_wrapper_integrity()
print()
print("🔐 Vault Secrets:")
check_vault_secrets()
def main():
quiet = "--quiet" in sys.argv
as_json = "--json" in sys.argv
# Self-deploy to canonical location
if not quiet and "--no-deploy" not in sys.argv:
deploy_self()
# Provenance: a report is only actionable if the reader can tell WHICH copy
# of this script produced it. A normal run carries it in the header, --json
# carries it for machine consumers, and the cron ALERT line carries it on
# failure. --quiet is documented as "only output on failure", so the header
# is emitted only when not quiet and a healthy quiet run stays silent.
script_path = os.path.abspath(__file__)
cwd = os.getcwd()
if quiet:
captured = io.StringIO()
with contextlib.redirect_stdout(captured):
load_agent_keys()
_run_checks()
if FAIL:
sys.stdout.write(captured.getvalue())
else:
print(f"🏥 Agent Health Check v4 — {datetime.now().strftime('%Y-%m-%d %H:%M UTC')}")
print(f"📍 executed from: script={script_path} cwd={cwd}")
print()
load_agent_keys()
_run_checks()
if FAIL:
print(f"\n❌ {len(FAIL)} FAILURE(S): {' | '.join(FAIL)}")
if quiet:
print(f"ALERT agent-health:{','.join(FAIL)} script={script_path} cwd={cwd}")
elif not quiet:
print("\n✅ All checks passed")
if as_json:
print(json.dumps({"timestamp": datetime.now().isoformat(),
"execution_path": script_path, "cwd": cwd,
"failures": FAIL, "report_only": REPORT_ONLY,
"healthy": len(FAIL) == 0}))
sys.exit(1 if FAIL else 0)
if __name__ == "__main__":
main()