#!/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. """ 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": "amdpve", "vault_key": "TANKO_LITELLM_API_KEY", "runtime": "dsh"}, # 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"}, "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 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"] # `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") port_owner = ssh(host, f"ss -tlnp 2>/dev/null | grep -Po ':{port}\\s+.*pid=\\K[0-9]+' | head -1") 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") 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") 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 on DSH (DeepSeek Harness) since 2026-08-27 — it no longer runs a # Hermes gateway, so skip the Hermes gateway/state/streaming/journal checks. # Non-Hermes runtimes have no gateway to probe. dsh = Tanko since # 2026-08-27; pi = abiba since the harness purge (.24 is pi-only). 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(): # Tanko runs on DSH (DeepSeek Harness) since 2026-08-27 — no Hermes config.yaml. 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 # Check YAML parses yaml_ok = ssh(host, "python3 -c " '"import yaml; yaml.safe_load(open(\'/root/.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(): # Tanko runs on DSH (DeepSeek Harness) since 2026-08-27 — no hermes CLI wrapper. 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 wrapper = ssh(host, "ls -la /root/.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, "cat /root/.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 hermes-real exists hermes_real = ssh(host, "ls -la /root/.local/bin/hermes-real 2>/dev/null || echo MISS", user=user) if not hermes_real or hermes_real.strip() == "MISS": # Check venv path hermes_real = ssh(host, "ls -la /usr/local/lib/hermes-agent/venv/bin/hermes 2>/dev/null || echo MISS", user=user) if not hermes_real or hermes_real.strip() == "MISS": print(f" ❌ {name}: hermes-real NOT FOUND (wrapper broken)") _fail(f"wrapper-no-hermes-real:{name}", name) else: print(f" ✅ {name}: hermes-real at alt path") # Check the .env file has the key env_has_key = ssh(host, "grep -c 'LITELLM_API_KEY' /root/.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()