#!/usr/bin/env python3 """RSI cron + ZTE auto-patch. Detects degraded skills, snapshots, issues signed /deploy actions, rolls back on failure.""" import hashlib import hmac import json import os import shutil import subprocess import sys import time import urllib.error import urllib.request from pathlib import Path from datetime import datetime SKILLS_DIR = Path(os.environ.get("HERMES_HOME", "/opt/data")) / "skills" SNAPSHOT_DIR = Path("/tmp/rsi-snapshots") DEPLOY_URL = os.environ.get("DEPLOY_URL", "http://localhost:8099/deploy") DEPLOY_TOKEN = os.environ.get("DEPLOY_TOKEN", "factory-deploy-token-2026") FAIL_THRESHOLD = 0.8 def run(cmd, timeout=30): return subprocess.run(cmd, shell=isinstance(cmd, str), capture_output=True, text=True, timeout=timeout) def signed_payload(action, target, actor="skill_health", payload_hash="none"): data = { "timestamp": int(time.time()), "nonce": hashlib.sha256(f"{time.time()}:{target}:{action}".encode()).hexdigest()[:16], "actor": actor, "action": action, "target": target, "payload_hash": payload_hash, } body = json.dumps({k: data[k] for k in ("timestamp", "nonce", "actor", "action", "target", "payload_hash")}, sort_keys=True, separators=(",", ":")).encode() data["signature"] = hmac.new(DEPLOY_TOKEN.encode(), body, hashlib.sha256).hexdigest() return data def post_deploy(payload, timeout=15): req = urllib.request.Request( DEPLOY_URL, data=json.dumps(payload).encode(), headers={"Authorization": f"Bearer {DEPLOY_TOKEN}", "Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(req, timeout=timeout) as r: return r.read().decode() except urllib.error.HTTPError as e: return e.read().decode() def check_skill(name): d = SKILLS_DIR / name t = d / "test.sh" if not t.exists(): return None r = run(["bash", str(t)]) if r.returncode == 0: Path("/opt/data/rsi-known-good").mkdir(exist_ok=True) (Path("/opt/data/rsi-known-good") / f"{name}.sig").write_text(hashlib.sha256(open(t, "rb").read()).hexdigest()[:16]) return {"skill": name, "passed": r.returncode == 0} def snapshot(): SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True) tag = datetime.now().strftime("%Y%m%d_%H%M%S") shutil.copytree(SKILLS_DIR, SNAPSHOT_DIR / tag, dirs_exist_ok=True) (SNAPSHOT_DIR / "latest").write_text(tag) return tag def rollback(): lf = SNAPSHOT_DIR / "latest" if not lf.exists(): return False snap = SNAPSHOT_DIR / lf.read_text().strip() if not snap.exists(): return False shutil.rmtree(SKILLS_DIR) shutil.copytree(snap, SKILLS_DIR, dirs_exist_ok=True) return True def auto_patch(failed): """ZTE: snapshot, request safe skill action, verify, rollback if still failing.""" print(f"🔄 ZTE: auto-patching {len(failed)} degraded skills") snap = snapshot() print(f"📸 Snap: {snap}") for f in failed: skill = f["skill"] print(f" Fixing {skill}...") payload_hash = hashlib.sha256(f"{snap}:{skill}".encode()).hexdigest()[:16] result = post_deploy(signed_payload("patch_skill_from_pr", skill, payload_hash=payload_hash)) print(f" Result: {result[:100]}") verify = [check_skill(f["skill"]) for f in failed] still_failing = [v for v in verify if v and not v["passed"]] if still_failing: print(f"⚠️ {len(still_failing)} still failing, rolling back...") rollback() print("✅ Rolled back") else: print("✅ All patched successfully") def main(): do_patch = "--auto-patch" in sys.argv results = [check_skill(d.name) for d in sorted(SKILLS_DIR.iterdir()) if d.is_dir() and (d / "test.sh").exists()] results = [r for r in results if r] passed = sum(1 for r in results if r["passed"]) total = len(results) score = round(passed / total, 2) if total else 0 failed = [r for r in results if not r["passed"]] for r in results: print(f"{'✅' if r['passed'] else '❌'} {r['skill']}") print(f"\nRSI: {passed}/{total} ({score:.0%})") if failed: print(f"Degraded: {[f['skill'] for f in failed]}") if do_patch: auto_patch(failed) else: print("Reported (no auto-patch)") else: print("All healthy — ZTE idle") if __name__ == "__main__": main()