diff --git a/agent/install-mac.sh b/agent/install-mac.sh new file mode 100644 index 0000000..9f5fda0 --- /dev/null +++ b/agent/install-mac.sh @@ -0,0 +1,135 @@ +#!/bin/bash +# JARVIS Agent Installer — macOS +# Usage: bash install-mac.sh --jarvis-url https://jarvis.orbishosting.com --key YOUR_KEY +# Or one-liner: bash <(curl -sSL https://jarvis.orbishosting.com/agent/install-mac.sh) --key YOUR_KEY + +set -e + +JARVIS_URL="https://jarvis.orbishosting.com" +REG_KEY="" +INSTALL_DIR="$HOME/.jarvis-agent" +PLIST_PATH="$HOME/Library/LaunchAgents/com.jarvis.agent.plist" +SERVICE_LABEL="com.jarvis.agent" + +while [[ $# -gt 0 ]]; do + case "$1" in + --jarvis-url) JARVIS_URL="$2"; shift 2 ;; + --key) REG_KEY="$2"; shift 2 ;; + *) echo "Unknown arg: $1"; exit 1 ;; + esac +done + +if [[ -z "$REG_KEY" ]]; then + read -rp "Registration key: " REG_KEY +fi + +JARVIS_URL="${JARVIS_URL%/}" + +echo "" +echo " ====================================" +echo " JARVIS Agent Installer v3.0 " +echo " ====================================" +echo "" + +# Check for Python3 +PYTHON3=$(command -v python3 2>/dev/null || "") +if [[ -z "$PYTHON3" ]]; then + echo "Python 3 is required. Install it with:" + echo " brew install python3" + echo " or download from https://www.python.org/downloads/" + exit 1 +fi +echo "Using Python: $PYTHON3 ($($PYTHON3 --version 2>&1))" + +mkdir -p "$INSTALL_DIR" + +# Download macOS-native agent script +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || echo "")" +if [[ -f "$SCRIPT_DIR/jarvis-agent-mac.py" ]]; then + cp "$SCRIPT_DIR/jarvis-agent-mac.py" "$INSTALL_DIR/jarvis-agent.py" + echo "Copied agent from local directory." +else + echo "Downloading macOS agent..." + curl -sSL "$JARVIS_URL/agent/jarvis-agent-mac.py" -o "$INSTALL_DIR/jarvis-agent.py" + echo "Downloaded." +fi +chmod +x "$INSTALL_DIR/jarvis-agent.py" + +HOSTNAME=$(hostname -f 2>/dev/null || hostname) +AGENT_ID="${HOSTNAME}_mac" + +# Write config (preserve existing) +if [[ ! -f "$INSTALL_DIR/config.json" ]]; then +cat > "$INSTALL_DIR/config.json" << JSONEOF +{ + "jarvis_url": "$JARVIS_URL", + "registration_key": "$REG_KEY", + "hostname": "$HOSTNAME", + "agent_id": "$AGENT_ID", + "agent_type": "macos", + "poll_interval": 30, + "heartbeat_every": 10, + "update_check_hours": 24, + "watch_services": [], + "allow_shell_commands": false +} +JSONEOF + chmod 600 "$INSTALL_DIR/config.json" + echo "Config written to $INSTALL_DIR/config.json" +else + echo "Config already exists — preserving $INSTALL_DIR/config.json" +fi + +# Write launchd plist +mkdir -p "$HOME/Library/LaunchAgents" +cat > "$PLIST_PATH" << PLISTEOF + + + + + Label + $SERVICE_LABEL + ProgramArguments + + $PYTHON3 + $INSTALL_DIR/jarvis-agent.py + + EnvironmentVariables + + JARVIS_CONFIG + $INSTALL_DIR/config.json + JARVIS_STATE + $INSTALL_DIR/state.json + + RunAtLoad + + KeepAlive + + StandardOutPath + $INSTALL_DIR/jarvis-agent.log + StandardErrorPath + $INSTALL_DIR/jarvis-agent.log + + +PLISTEOF + +# Load/reload the service +launchctl unload "$PLIST_PATH" 2>/dev/null || true +launchctl load "$PLIST_PATH" + +sleep 2 +if launchctl list 2>/dev/null | grep -q "$SERVICE_LABEL"; then + echo "" + echo " JARVIS Agent installed and running." + echo " Machine : $HOSTNAME ($AGENT_ID)" + echo " JARVIS : $JARVIS_URL" + echo " Logs : tail -f $INSTALL_DIR/jarvis-agent.log" + echo " Config : $INSTALL_DIR/config.json" + echo " Stop : launchctl unload $PLIST_PATH" + echo " Update : curl -sSL $JARVIS_URL/agent/install-mac.sh | bash -s -- --key $REG_KEY" +else + echo "" + echo " Agent installed but not detected as running. Check logs:" + echo " tail -f $INSTALL_DIR/jarvis-agent.log" +fi +echo "" diff --git a/agent/jarvis-agent-mac.py b/agent/jarvis-agent-mac.py new file mode 100644 index 0000000..6bd487a --- /dev/null +++ b/agent/jarvis-agent-mac.py @@ -0,0 +1,576 @@ +#!/usr/bin/env python3 +""" +JARVIS Agent for macOS — system monitor that reports metrics to JARVIS HUD. +Install: bash <(curl -sSL https://jarvis.orbishosting.com/agent/install-mac.sh) --key YOUR_KEY +Config: ~/.jarvis-agent/config.json (or $JARVIS_CONFIG) +Logs: ~/.jarvis-agent/jarvis-agent.log (or journalctl via launchd) +""" + +import json +import os +import platform +import re +import socket +import subprocess +import sys +import time +import urllib.request +import urllib.error +import hashlib +from datetime import datetime +from pathlib import Path + +AGENT_VERSION = "3.0" + +# Config/state paths — env vars let the launchd plist override them +_default_dir = Path.home() / ".jarvis-agent" +CONFIG_PATH = Path(os.environ.get("JARVIS_CONFIG", str(_default_dir / "config.json"))) +STATE_PATH = Path(os.environ.get("JARVIS_STATE", str(_default_dir / "state.json"))) +LOG_PATH = _default_dir / "jarvis-agent.log" + +# ── Logging ──────────────────────────────────────────────────────────────────── + +def log(msg: str): + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + line = f"[{ts}] {msg}" + print(line, flush=True) + try: + LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(LOG_PATH, "a") as f: + f.write(line + "\n") + except Exception: + pass + +# ── Config ───────────────────────────────────────────────────────────────────── + +def load_config() -> dict: + if not CONFIG_PATH.exists(): + print(f"[ERROR] Config not found at {CONFIG_PATH}. Run the installer first.") + sys.exit(1) + with open(CONFIG_PATH) as f: + return json.load(f) + +def load_state() -> dict: + if STATE_PATH.exists(): + with open(STATE_PATH) as f: + return json.load(f) + return {} + +def save_state(state: dict): + STATE_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(STATE_PATH, "w") as f: + json.dump(state, f, indent=2) + +# ── HTTP ─────────────────────────────────────────────────────────────────────── + +import ssl as _ssl + +def _make_ssl_ctx(verify: bool): + if not verify: + ctx = _ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = _ssl.CERT_NONE + return ctx + return None + +_host_header: str = "" + +def api_post(url: str, payload: dict, headers: dict = {}, timeout: int = 15, + ssl_verify: bool = True) -> dict: + body = json.dumps(payload).encode() + req = urllib.request.Request(url, data=body, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("User-Agent", "JARVIS-Agent-macOS/3.0") + if _host_header: + req.add_header("Host", _host_header) + for k, v in headers.items(): + req.add_header(k, v) + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + return {"error": f"HTTP {e.code}: {e.read().decode()[:200]}"} + except Exception as e: + return {"error": str(e)} + +def api_get(url: str, headers: dict = {}, timeout: int = 10, + ssl_verify: bool = True) -> dict: + req = urllib.request.Request(url) + req.add_header("User-Agent", "JARVIS-Agent-macOS/3.0") + if _host_header: + req.add_header("Host", _host_header) + for k, v in headers.items(): + req.add_header(k, v) + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode()) + except Exception as e: + return {"error": str(e)} + +# ── Metrics ──────────────────────────────────────────────────────────────────── + +def get_local_ip() -> str: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "unknown" + +_last_cpu_times = None + +def get_cpu_percent() -> float: + """Delta-based CPU % using top (two samples).""" + global _last_cpu_times + try: + # top -l 2 gives two snapshots; second line has the real delta + r = subprocess.run( + ["top", "-l", "2", "-s", "0", "-n", "0"], + capture_output=True, text=True, timeout=12 + ) + idle = None + for line in r.stdout.splitlines(): + if "CPU usage:" in line: + m = re.search(r"([\d.]+)%\s+idle", line) + if m: + idle = float(m.group(1)) + if idle is not None: + return round(100.0 - idle, 1) + except Exception: + pass + return 0.0 + +def get_memory() -> dict: + try: + # Total physical memory + r = subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=3) + total_bytes = int(r.stdout.strip()) + + # vm_stat for page counts; default page size on Apple Silicon and Intel = 4096 + page_size = 4096 + try: + ps_r = subprocess.run(["sysctl", "-n", "hw.pagesize"], capture_output=True, text=True, timeout=3) + page_size = int(ps_r.stdout.strip()) + except Exception: + pass + + vm_r = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=5) + pages = {} + for line in vm_r.stdout.splitlines(): + m = re.match(r"Pages\s+(.+?):\s+([\d]+)", line) + if m: + pages[m.group(1).strip().lower()] = int(m.group(2)) + + free_pages = pages.get("free", 0) + pages.get("speculative", 0) + # "available" = free + inactive (can be reclaimed) + avail_pages = free_pages + pages.get("inactive", 0) + used_pages = (total_bytes // page_size) - avail_pages + + total_mb = round(total_bytes / (1024 * 1024), 1) + used_mb = round(used_pages * page_size / (1024 * 1024), 1) + avail_mb = round(avail_pages * page_size / (1024 * 1024), 1) + used_mb = max(0, min(used_mb, total_mb)) + + return { + "total_mb": total_mb, + "used_mb": used_mb, + "free_mb": avail_mb, + "percent": round(used_mb / total_mb * 100, 1) if total_mb else 0, + } + except Exception: + return {} + +def get_disk() -> list: + disks = [] + try: + r = subprocess.run(["df", "-H", "-l"], capture_output=True, text=True, timeout=5) + for line in r.stdout.splitlines()[1:]: + parts = line.split() + if len(parts) >= 6: + mount = parts[5] + if not any(mount.startswith(x) for x in ["/dev", "/private/var/vm", "/System/Volumes/VM"]): + disks.append({ + "mount": mount, + "size": parts[1], + "used": parts[2], + "avail": parts[3], + "percent": parts[4].rstrip("%"), + }) + except Exception: + pass + return disks + +def get_uptime() -> dict: + try: + r = subprocess.run(["sysctl", "-n", "kern.boottime"], capture_output=True, text=True, timeout=3) + m = re.search(r"sec\s*=\s*(\d+)", r.stdout) + if m: + boot_ts = int(m.group(1)) + secs = time.time() - boot_ts + days = int(secs // 86400) + hours = int((secs % 86400) // 3600) + minutes = int((secs % 3600) // 60) + return {"seconds": int(secs), "days": days, "hours": hours, "minutes": minutes, + "human": f"{days}d {hours}h {minutes}m"} + except Exception: + pass + return {} + +def get_load() -> list: + try: + r = subprocess.run(["sysctl", "-n", "vm.loadavg"], capture_output=True, text=True, timeout=3) + # format: "{ 0.12 0.34 0.56 }" + nums = re.findall(r"[\d.]+", r.stdout) + if len(nums) >= 3: + return [float(nums[0]), float(nums[1]), float(nums[2])] + except Exception: + pass + return [0, 0, 0] + +def get_services(cfg: dict) -> list: + """Check macOS LaunchDaemon/LaunchAgent services via launchctl.""" + watch = cfg.get("watch_services", []) + if not watch: + return [] + statuses = [] + try: + r = subprocess.run(["launchctl", "list"], capture_output=True, text=True, timeout=5) + running = set() + for line in r.stdout.splitlines(): + parts = line.split() + if len(parts) >= 3: + running.add(parts[2]) + except Exception: + running = set() + for svc in watch: + status = "active" if any(svc in s for s in running) else "inactive" + statuses.append({"service": svc, "status": status}) + return statuses + +def detect_capabilities(cfg: dict) -> list: + import shutil + caps = ["metrics", "commands"] + if shutil.which("docker"): + caps.append("docker") + if shutil.which("ollama"): + caps.append("ollama") + # screencapture is always available on macOS + caps.append("screenshot") + caps.append("sysinfo") + return caps + +def collect_metrics(cfg: dict) -> dict: + return { + "hostname": cfg.get("hostname", socket.gethostname()), + "cpu_percent": get_cpu_percent(), + "memory": get_memory(), + "disk": get_disk(), + "uptime": get_uptime(), + "load": get_load(), + "services": get_services(cfg), + "platform": "macOS", + "os_version": platform.mac_ver()[0], + "timestamp": datetime.utcnow().isoformat() + "Z", + } + +# ── Registration ─────────────────────────────────────────────────────────────── + +def register(cfg: dict, state: dict) -> str: + hostname = cfg.get("hostname", socket.gethostname()) + agent_type = cfg.get("agent_type", "macos") + ip = get_local_ip() + capabilities = detect_capabilities(cfg) + agent_id = cfg.get("agent_id", f"{hostname}_mac") + ssl_verify = bool(cfg.get("ssl_verify", True)) + + log(f"Registering as '{agent_id}' ({agent_type}) from {ip}...") + + result = api_post( + f"{cfg['jarvis_url']}/api/agent/register", + {"hostname": hostname, "agent_type": agent_type, "ip_address": ip, + "capabilities": capabilities, "agent_id": agent_id}, + headers={"X-Registration-Key": cfg["registration_key"]}, + ssl_verify=ssl_verify, + ) + + if "error" in result: + log(f"[ERROR] Registration failed: {result['error']}") + return "" + + api_key = result.get("api_key", "") + if api_key: + state["api_key"] = api_key + state["agent_id"] = result.get("agent_id", agent_id) + save_state(state) + log(f"Registered. agent_id={state['agent_id']}") + return api_key + +# ── Screenshot ───────────────────────────────────────────────────────────────── + +def _take_screenshot(cmd_data: dict) -> dict: + import base64, tempfile, shutil + tmp = tempfile.mktemp(suffix=".png") + method = "unknown" + + # screencapture -x = no sound, always available on macOS + try: + r = subprocess.run(["screencapture", "-x", "-t", "png", tmp], + capture_output=True, timeout=15) + if r.returncode == 0 and os.path.exists(tmp): + method = "screencapture" + except Exception: + pass + + # Fallback: PIL + if method == "unknown": + try: + from PIL import ImageGrab + img = ImageGrab.grab() + img.save(tmp, "PNG") + method = "pil" + except Exception: + pass + + if method == "unknown" or not os.path.exists(tmp): + snap = _sysinfo_snapshot() + snap["screenshot_available"] = False + snap["method"] = "text_only" + return snap + + try: + with open(tmp, "rb") as f: + raw = f.read() + b64 = base64.b64encode(raw).decode() + try: + os.unlink(tmp) + except Exception: + pass + return { + "success": True, + "method": method, + "image_b64": b64, + "image_mime": "image/png", + "file_size": len(raw), + "hostname": socket.gethostname(), + } + except Exception as e: + return {"success": False, "error": str(e), "method": method} + +# ── Sysinfo ──────────────────────────────────────────────────────────────────── + +def _sysinfo_snapshot() -> dict: + data = {"success": True, "hostname": socket.gethostname(), + "snapshot_type": "text", "screenshot_available": False, "platform": "macOS"} + try: + mem = get_memory() + data["mem_total_mb"] = int(mem.get("total_mb", 0)) + data["mem_used_mb"] = int(mem.get("used_mb", 0)) + data["mem_avail_mb"] = int(mem.get("free_mb", 0)) + except Exception: + pass + try: + load = get_load() + data["load_1m"], data["load_5m"], data["load_15m"] = load[0], load[1], load[2] + except Exception: + pass + try: + r = subprocess.run(["df", "-H", "/"], capture_output=True, text=True, timeout=5) + data["disk"] = r.stdout.splitlines()[1] if r.stdout else "" + except Exception: + pass + try: + r = subprocess.run(["ps", "aux", "-r"], capture_output=True, text=True, timeout=5) + data["top_procs"] = r.stdout.splitlines()[1:8] + except Exception: + pass + try: + r = subprocess.run(["netstat", "-an", "-p", "tcp"], capture_output=True, text=True, timeout=5) + listening = [l for l in r.stdout.splitlines() if "LISTEN" in l] + data["listening_ports"] = "\n".join(listening[:20]) + except Exception: + pass + return data + +# ── Command execution ────────────────────────────────────────────────────────── + +def execute_command(cmd: dict) -> dict: + cmd_type = cmd.get("command_type", "") + cmd_data = cmd.get("command_data", {}) + try: + if cmd_type == "ping": + host = cmd_data.get("host", "8.8.8.8") + r = subprocess.run(["ping", "-c", "3", "-W", "2000", host], + capture_output=True, text=True, timeout=15) + return {"success": r.returncode == 0, "output": r.stdout} + + elif cmd_type == "update": + _cfg = load_config() + updated = self_update(_cfg) + return {"success": True, "updated": updated} + + elif cmd_type == "shell": + _cfg = load_config() + if not _cfg.get("allow_shell_commands", False): + return {"success": False, "error": "Shell commands not enabled in agent config"} + cmd_str = cmd_data.get("command", "") + r = subprocess.run(cmd_str, shell=True, capture_output=True, text=True, timeout=30) + return {"success": True, "stdout": r.stdout[:2000], "stderr": r.stderr[:500]} + + elif cmd_type == "restart_service": + svc = cmd_data.get("service", "") + if not svc or "/" in svc: + return {"success": False, "error": "Invalid service name"} + r = subprocess.run(["launchctl", "kickstart", "-k", f"system/{svc}"], + capture_output=True, text=True, timeout=15) + if r.returncode != 0: + r = subprocess.run(["brew", "services", "restart", svc], + capture_output=True, text=True, timeout=15) + return {"success": r.returncode == 0, "stdout": r.stdout, "stderr": r.stderr} + + elif cmd_type == "get_logs": + svc = cmd_data.get("service", "") + lines = min(int(cmd_data.get("lines", 50)), 200) + r = subprocess.run(["log", "show", "--predicate", f'process == "{svc}"', + "--last", "1h", "--style", "compact"], + capture_output=True, text=True, timeout=15) + output = "\n".join(r.stdout.splitlines()[-lines:]) + return {"success": True, "output": output} + + elif cmd_type == "screenshot": + return _take_screenshot(cmd_data) + + elif cmd_type == "sysinfo": + return _sysinfo_snapshot() + + else: + return {"success": False, "error": f"Unknown command type: {cmd_type}"} + + except subprocess.TimeoutExpired: + return {"success": False, "error": "Command timed out"} + except Exception as e: + return {"success": False, "error": str(e)} + +# ── Self-update ──────────────────────────────────────────────────────────────── + +def self_update(cfg: dict) -> bool: + jarvis_url = cfg.get("jarvis_url", "").rstrip("/") + default_update_url = f"{jarvis_url}/agent/jarvis-agent-mac.py" if jarvis_url else "" + update_url = cfg.get("update_url", default_update_url) + if not update_url: + return False + script_path = os.path.abspath(__file__) + ssl_verify = bool(cfg.get("ssl_verify", True)) + try: + # Download expected hash + hash_url = update_url + ".sha256" + req_hash = urllib.request.Request(hash_url) + req_hash.add_header("User-Agent", "JARVIS-Agent-macOS/3.0") + if _host_header: + req_hash.add_header("Host", _host_header) + expected_hash = None + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req_hash, timeout=10, context=ctx) as resp: + expected_hash = resp.read().decode().strip().split()[0] + except Exception: + pass + + # Download new script + req = urllib.request.Request(update_url) + req.add_header("User-Agent", "JARVIS-Agent-macOS/3.0") + if _host_header: + req.add_header("Host", _host_header) + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=30, context=ctx) as resp: + new_content = resp.read() + + if expected_hash: + actual_hash = hashlib.sha256(new_content).hexdigest() + if actual_hash != expected_hash: + log(f"Update hash mismatch (expected {expected_hash[:16]}… got {actual_hash[:16]}…) — aborting") + return False + + with open(script_path, "rb") as f: + current = f.read() + if new_content != current: + log(f"Update verified — replacing {script_path} and restarting...") + with open(script_path, "wb") as f: + f.write(new_content) + os.execv(sys.executable, [sys.executable] + sys.argv) + return True + return False + except Exception as e: + log(f"Self-update check failed: {e}") + return False + +# ── Main loop ────────────────────────────────────────────────────────────────── + +def main(): + global _host_header + cfg = load_config() + state = load_state() + + jarvis_url = cfg["jarvis_url"].rstrip("/") + ssl_verify = bool(cfg.get("ssl_verify", True)) + _host_header = cfg.get("host_header", "") + poll_interval = int(cfg.get("poll_interval", 30)) + heartbeat_every = int(cfg.get("heartbeat_every", 10)) + update_interval = int(cfg.get("update_check_hours", 24)) * 3600 + + # Always re-register on startup to refresh capabilities, version, IP + api_key = state.get("api_key", "") + registered_key = register(cfg, state) + if registered_key: + api_key = registered_key + elif not api_key: + while not api_key: + api_key = register(cfg, state) + if not api_key: + log("[ERROR] Could not register with JARVIS. Retrying in 60s...") + time.sleep(60) + + headers = {"X-Agent-Key": api_key} + last_metrics = 0 + last_update_chk = 0 + + log(f"Agent v{AGENT_VERSION} (macOS) running. Polling {jarvis_url} every {heartbeat_every}s.") + + while True: + tick_start = time.time() + now = tick_start + try: + hb = api_post(f"{jarvis_url}/api/agent/heartbeat", {}, headers, ssl_verify=ssl_verify) + if "error" in hb: + log(f"[WARN] Heartbeat failed: {hb['error']}") + else: + for cmd in hb.get("commands", []): + log(f"[CMD] Executing: {cmd['command_type']}") + result = execute_command(cmd) + api_post(f"{jarvis_url}/api/agent/command_result", + {"command_id": cmd["id"], "success": result.get("success", False), "result": result}, + headers, ssl_verify=ssl_verify) + + # Self-update check + if now - last_update_chk >= update_interval: + last_update_chk = now + self_update(cfg) + + # Push metrics + if now - last_metrics >= poll_interval: + metrics = collect_metrics(cfg) + api_post(f"{jarvis_url}/api/agent/metrics", + {"type": "system", "data": metrics}, headers, ssl_verify=ssl_verify) + last_metrics = now + + except Exception as e: + log(f"[ERROR] Loop error: {e}") + + time.sleep(heartbeat_every) + + +if __name__ == "__main__": + main() diff --git a/agent/jarvis-agent-windows.py b/agent/jarvis-agent-windows.py new file mode 100644 index 0000000..d66655c --- /dev/null +++ b/agent/jarvis-agent-windows.py @@ -0,0 +1,534 @@ +#!/usr/bin/env python3 +""" +JARVIS Agent for Windows — system monitor that reports metrics to JARVIS HUD. +Install via PowerShell (as Admin): irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex +Config: C:\ProgramData\jarvis-agent\config.json +Logs: C:\ProgramData\jarvis-agent\jarvis-agent.log +""" + +import json +import os +import platform +import socket +import subprocess +import sys +import time +import urllib.request +import urllib.error +import hashlib +from datetime import datetime +from pathlib import Path + +INSTALL_DIR = Path(r"C:\ProgramData\jarvis-agent") +CONFIG_PATH = INSTALL_DIR / "config.json" +STATE_PATH = INSTALL_DIR / "state.json" +LOG_PATH = INSTALL_DIR / "jarvis-agent.log" +AGENT_VERSION = "3.0" + +# ── Logging ──────────────────────────────────────────────────────────────────── + +def log(msg: str): + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + line = f"[{ts}] {msg}" + print(line, flush=True) + try: + with open(LOG_PATH, "a", encoding="utf-8") as f: + f.write(line + "\n") + except Exception: + pass + +# ── Config ───────────────────────────────────────────────────────────────────── + +def load_config() -> dict: + if not CONFIG_PATH.exists(): + print(f"[ERROR] Config not found at {CONFIG_PATH}. Run the installer first.") + sys.exit(1) + with open(CONFIG_PATH, encoding="utf-8") as f: + return json.load(f) + +def load_state() -> dict: + if STATE_PATH.exists(): + with open(STATE_PATH, encoding="utf-8") as f: + return json.load(f) + return {} + +def save_state(state: dict): + INSTALL_DIR.mkdir(parents=True, exist_ok=True) + with open(STATE_PATH, "w", encoding="utf-8") as f: + json.dump(state, f, indent=2) + +# ── HTTP ─────────────────────────────────────────────────────────────────────── + +import ssl as _ssl + +def _make_ssl_ctx(verify: bool): + if not verify: + ctx = _ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = _ssl.CERT_NONE + return ctx + return None + +_host_header: str = "" + +def api_post(url: str, payload: dict, headers: dict = {}, timeout: int = 15, + ssl_verify: bool = True) -> dict: + body = json.dumps(payload).encode() + req = urllib.request.Request(url, data=body, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0") + if _host_header: + req.add_header("Host", _host_header) + for k, v in headers.items(): + req.add_header(k, v) + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + return {"error": f"HTTP {e.code}: {e.read().decode()[:200]}"} + except Exception as e: + return {"error": str(e)} + +def api_get(url: str, headers: dict = {}, timeout: int = 10, + ssl_verify: bool = True) -> dict: + req = urllib.request.Request(url) + req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0") + if _host_header: + req.add_header("Host", _host_header) + for k, v in headers.items(): + req.add_header(k, v) + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode()) + except Exception as e: + return {"error": str(e)} + +# ── PowerShell helper ────────────────────────────────────────────────────────── + +def _ps(script: str, timeout: int = 8) -> str: + try: + r = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", script], + capture_output=True, text=True, timeout=timeout + ) + return r.stdout.strip() + except Exception: + return "" + +# ── Metrics ──────────────────────────────────────────────────────────────────── + +def get_local_ip() -> str: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "unknown" + +def get_cpu_percent() -> float: + try: + out = _ps("(Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average") + return round(float(out), 1) + except Exception: + return 0.0 + +def get_memory() -> dict: + try: + out = _ps("$o=Get-CimInstance Win32_OperatingSystem; [PSCustomObject]@{total=$o.TotalVisibleMemorySize;free=$o.FreePhysicalMemory}|ConvertTo-Json") + d = json.loads(out) + total_kb = int(d.get("total", 0)) + free_kb = int(d.get("free", 0)) + used_kb = total_kb - free_kb + if total_kb == 0: + return {} + return { + "total_mb": round(total_kb / 1024, 1), + "used_mb": round(used_kb / 1024, 1), + "free_mb": round(free_kb / 1024, 1), + "percent": round(used_kb / total_kb * 100, 1), + } + except Exception: + return {} + +def get_disk() -> list: + try: + out = _ps("Get-PSDrive -PSProvider FileSystem | Where-Object{$_.Used -ne $null} | Select-Object Name,@{n='used';e={[math]::Round($_.Used/1GB,2)}},@{n='free';e={[math]::Round($_.Free/1GB,2)}} | ConvertTo-Json") + if not out: + return [] + items = json.loads(out) + if isinstance(items, dict): + items = [items] + disks = [] + for d in items: + used = float(d.get("used", 0)) + free = float(d.get("free", 0)) + total = used + free + pct = round(used / total * 100, 1) if total else 0 + disks.append({ + "mount": d.get("Name", "?") + ":\\", + "size": f"{round(total, 1)}G", + "used": f"{used}G", + "avail": f"{free}G", + "percent": str(int(pct)), + }) + return disks + except Exception: + return [] + +def get_uptime() -> dict: + try: + out = _ps("(Get-Date) - (gcim Win32_OperatingSystem).LastBootUpTime | Select-Object -ExpandProperty TotalSeconds") + secs = float(out) + days = int(secs // 86400) + hours = int((secs % 86400) // 3600) + minutes = int((secs % 3600) // 60) + return {"seconds": int(secs), "days": days, "hours": hours, "minutes": minutes, + "human": f"{days}d {hours}h {minutes}m"} + except Exception: + return {} + +def get_services(cfg: dict) -> list: + watch = cfg.get("watch_services", ["WinDefend", "Spooler"]) + statuses = [] + for svc in watch: + try: + out = _ps(f"(Get-Service -Name '{svc}' -ErrorAction SilentlyContinue).Status") + status = "active" if out.lower() == "running" else (out.lower() or "unknown") + statuses.append({"service": svc, "status": status}) + except Exception: + statuses.append({"service": svc, "status": "unknown"}) + return statuses + +def detect_capabilities(cfg: dict) -> list: + caps = ["metrics", "commands"] + import shutil + if Path(r"C:\Program Files\Docker\Docker\Docker Desktop.exe").exists(): + caps.append("docker") + # Screenshot via PIL or PowerShell .NET (always available on Windows) + caps.append("screenshot") + caps.append("sysinfo") + return caps + +def collect_metrics(cfg: dict) -> dict: + return { + "hostname": cfg.get("hostname", socket.gethostname()), + "cpu_percent": get_cpu_percent(), + "memory": get_memory(), + "disk": get_disk(), + "uptime": get_uptime(), + "load": [0, 0, 0], + "services": get_services(cfg), + "platform": "Windows", + "os_version": platform.version(), + "timestamp": datetime.utcnow().isoformat() + "Z", + } + +# ── Registration ─────────────────────────────────────────────────────────────── + +def register(cfg: dict, state: dict) -> str: + hostname = cfg.get("hostname", socket.gethostname().lower()) + agent_type = cfg.get("agent_type", "windows") + ip = get_local_ip() + capabilities = detect_capabilities(cfg) + agent_id = cfg.get("agent_id", f"{hostname}_windows") + ssl_verify = bool(cfg.get("ssl_verify", True)) + + log(f"Registering as '{agent_id}' ({agent_type}) from {ip}...") + + result = api_post( + f"{cfg['jarvis_url']}/api/agent/register", + {"hostname": hostname, "agent_type": agent_type, "ip_address": ip, + "capabilities": capabilities, "agent_id": agent_id}, + headers={"X-Registration-Key": cfg["registration_key"]}, + ssl_verify=ssl_verify, + ) + + if "error" in result: + log(f"[ERROR] Registration failed: {result['error']}") + return "" + + api_key = result.get("api_key", "") + if api_key: + state["api_key"] = api_key + state["agent_id"] = result.get("agent_id", agent_id) + save_state(state) + log(f"Registered. agent_id={state['agent_id']}") + return api_key + +# ── Screenshot ───────────────────────────────────────────────────────────────── + +def _take_screenshot(cmd_data: dict) -> dict: + import base64, tempfile + tmp = str(INSTALL_DIR / "screenshot_tmp.png") + method = "unknown" + + # Try PIL/Pillow first + try: + from PIL import ImageGrab + img = ImageGrab.grab(all_screens=True) + img.save(tmp, "PNG") + method = "pil" + except ImportError: + pass + except Exception: + pass + + # Fallback: PowerShell .NET screenshot (no extra packages needed) + if method == "unknown": + ps_script = ( + "Add-Type -AssemblyName System.Windows.Forms,System.Drawing; " + "$s=[System.Windows.Forms.SystemInformation]::VirtualScreen; " + "$bmp=New-Object System.Drawing.Bitmap($s.Width,$s.Height); " + "$g=[System.Drawing.Graphics]::FromImage($bmp); " + "$g.CopyFromScreen($s.Location,[System.Drawing.Point]::Empty,$s.Size); " + f"$bmp.Save('{tmp}'); $g.Dispose(); $bmp.Dispose()" + ) + try: + r = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script], + capture_output=True, timeout=15 + ) + if r.returncode == 0 and os.path.exists(tmp): + method = "powershell" + except Exception: + pass + + if method == "unknown" or not os.path.exists(tmp): + return _sysinfo_snapshot() + + try: + with open(tmp, "rb") as f: + raw = f.read() + b64 = base64.b64encode(raw).decode() + try: + os.unlink(tmp) + except Exception: + pass + return { + "success": True, + "method": method, + "image_b64": b64, + "image_mime": "image/png", + "file_size": len(raw), + "hostname": socket.gethostname(), + } + except Exception as e: + return {"success": False, "error": str(e), "method": method} + +# ── Sysinfo ──────────────────────────────────────────────────────────────────── + +def _sysinfo_snapshot() -> dict: + data = {"success": True, "hostname": socket.gethostname(), + "snapshot_type": "text", "screenshot_available": False, "platform": "Windows"} + try: + mem = get_memory() + data["mem_total_mb"] = int(mem.get("total_mb", 0)) + data["mem_used_mb"] = int(mem.get("used_mb", 0)) + data["mem_avail_mb"] = int(mem.get("free_mb", 0)) + except Exception: + pass + try: + data["cpu_percent"] = get_cpu_percent() + except Exception: + pass + try: + data["disk"] = get_disk() + except Exception: + pass + try: + ut = get_uptime() + data["uptime"] = ut.get("human", "") + except Exception: + pass + try: + out = _ps("Get-Process | Sort-Object CPU -Descending | Select-Object -First 8 Name,CPU,WorkingSet | ConvertTo-Json") + data["top_procs"] = json.loads(out) if out else [] + except Exception: + pass + try: + out = _ps("netstat -an | findstr LISTENING | head -20") + data["listening_ports"] = out[:800] + except Exception: + pass + return data + +# ── Self-update ──────────────────────────────────────────────────────────────── + +def self_update(cfg: dict) -> bool: + jarvis_url = cfg.get("jarvis_url", "").rstrip("/") + default_update_url = f"{jarvis_url}/agent/jarvis-agent-windows.py" if jarvis_url else "" + update_url = cfg.get("update_url", default_update_url) + if not update_url: + return False + script_path = os.path.abspath(__file__) + ssl_verify = bool(cfg.get("ssl_verify", True)) + try: + # Download expected hash + hash_url = update_url + ".sha256" + req_hash = urllib.request.Request(hash_url) + req_hash.add_header("User-Agent", "JARVIS-Agent-Windows/3.0") + if _host_header: + req_hash.add_header("Host", _host_header) + expected_hash = None + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req_hash, timeout=10, context=ctx) as resp: + expected_hash = resp.read().decode().strip().split()[0] + except Exception: + pass + + # Download new script + req = urllib.request.Request(update_url) + req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0") + if _host_header: + req.add_header("Host", _host_header) + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=30, context=ctx) as resp: + new_content = resp.read() + + # Verify hash + if expected_hash: + actual_hash = hashlib.sha256(new_content).hexdigest() + if actual_hash != expected_hash: + log(f"Update hash mismatch (expected {expected_hash[:16]}… got {actual_hash[:16]}…) — aborting") + return False + + with open(script_path, "rb") as f: + current = f.read() + if new_content != current: + log(f"Update verified — replacing {script_path} and restarting...") + with open(script_path, "wb") as f: + f.write(new_content) + os.execv(sys.executable, [sys.executable] + sys.argv) + return True + return False + except Exception as e: + log(f"Self-update check failed: {e}") + return False + +# ── Command execution ────────────────────────────────────────────────────────── + +def execute_command(cmd: dict, cfg: dict) -> dict: + cmd_type = cmd.get("command_type", "") + cmd_data = cmd.get("command_data", {}) + try: + if cmd_type == "ping": + host = cmd_data.get("host", "8.8.8.8") + r = subprocess.run(["ping", "-n", "3", host], capture_output=True, text=True, timeout=15) + return {"success": r.returncode == 0, "output": r.stdout} + + elif cmd_type == "update": + log("[CMD] Self-update requested") + updated = self_update(cfg) + return {"success": True, "updated": updated} + + elif cmd_type == "shell": + # Guard reads LOCAL config — never trust server-supplied flags + _cfg = load_config() + if not _cfg.get("allow_shell_commands", False): + return {"success": False, "error": "Shell commands not enabled in agent config"} + cmd_str = cmd_data.get("command", "") + r = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd_str], + capture_output=True, text=True, timeout=30) + return {"success": True, "stdout": r.stdout[:2000], "stderr": r.stderr[:500]} + + elif cmd_type == "restart_service": + svc = cmd_data.get("service", "") + if not svc: + return {"success": False, "error": "No service specified"} + out = _ps(f"Restart-Service -Name '{svc}' -Force -ErrorAction Stop; 'ok'") + return {"success": "ok" in out.lower(), "output": out} + + elif cmd_type == "get_logs": + svc = cmd_data.get("service", "") + lines = min(int(cmd_data.get("lines", 50)), 200) + if not svc: + return {"success": False, "error": "No service specified"} + out = _ps(f"Get-EventLog -LogName Application -Source '{svc}' -Newest {lines} -ErrorAction SilentlyContinue | Format-List TimeGenerated,EntryType,Message | Out-String") + return {"success": True, "output": out[:3000]} + + elif cmd_type == "screenshot": + return _take_screenshot(cmd_data) + + elif cmd_type == "sysinfo": + return _sysinfo_snapshot() + + else: + return {"success": False, "error": f"Unknown command type: {cmd_type}"} + + except subprocess.TimeoutExpired: + return {"success": False, "error": "Command timed out"} + except Exception as e: + return {"success": False, "error": str(e)} + +# ── Main loop ────────────────────────────────────────────────────────────────── + +def main(): + global _host_header + + cfg = load_config() + state = load_state() + + jarvis_url = cfg["jarvis_url"].rstrip("/") + ssl_verify = bool(cfg.get("ssl_verify", True)) + _host_header = cfg.get("host_header", "") + poll_interval = int(cfg.get("poll_interval", 30)) + heartbeat_every = int(cfg.get("heartbeat_every", 10)) + update_interval = int(cfg.get("update_check_hours", 24)) * 3600 + + # Always re-register on startup to refresh capabilities, version, IP + api_key = state.get("api_key", "") + registered_key = register(cfg, state) + if registered_key: + api_key = registered_key + elif not api_key: + while not api_key: + api_key = register(cfg, state) + if not api_key: + log("[ERROR] Could not register with JARVIS. Retrying in 60s...") + time.sleep(60) + + headers = {"X-Agent-Key": api_key} + last_metrics = 0 + last_update_chk = 0 + + log(f"Agent v{AGENT_VERSION} (Windows) running. Polling {jarvis_url} every {heartbeat_every}s.") + + while True: + now = time.time() + try: + hb = api_post(f"{jarvis_url}/api/agent/heartbeat", {}, headers, ssl_verify=ssl_verify) + if "error" in hb: + log(f"[WARN] Heartbeat failed: {hb['error']}") + else: + for cmd in hb.get("commands", []): + log(f"[CMD] Executing: {cmd['command_type']}") + result = execute_command(cmd, cfg) + api_post(f"{jarvis_url}/api/agent/command_result", + {"command_id": cmd["id"], "success": result.get("success", False), "result": result}, + headers, ssl_verify=ssl_verify) + + # Self-update check + if now - last_update_chk >= update_interval: + last_update_chk = now + self_update(cfg) + + # Push metrics + if now - last_metrics >= poll_interval: + metrics = collect_metrics(cfg) + api_post(f"{jarvis_url}/api/agent/metrics", + {"type": "system", "data": metrics}, headers, ssl_verify=ssl_verify) + last_metrics = now + + except Exception as e: + log(f"[ERROR] Loop error: {e}") + + time.sleep(heartbeat_every) + + +if __name__ == "__main__": + main() diff --git a/agent/jarvis-agent.py b/agent/jarvis-agent.py index 8dcfe1e..4494c7a 100755 --- a/agent/jarvis-agent.py +++ b/agent/jarvis-agent.py @@ -23,7 +23,7 @@ from pathlib import Path CONFIG_PATH = "/etc/jarvis-agent/config.json" STATE_PATH = "/var/lib/jarvis-agent/state.json" -AGENT_VERSION = "3.0" # Phase 4: screenshot + sysinfo commands +AGENT_VERSION = "3.1" # ── Config helpers ──────────────────────────────────────────────────────────── @@ -119,12 +119,6 @@ def detect_capabilities(cfg: dict) -> list: # Check for Home Assistant if os.path.exists("/etc/homeassistant") or os.path.exists("/config/configuration.yaml"): caps.append("homeassistant") - # Phase 4: screenshot capability - import shutil as _shutil - if (_shutil.which("scrot") or _shutil.which("import") or - _shutil.which("fbcat") or _shutil.which("convert")): - caps.append("screenshot") - caps.append("sysinfo") return caps def register(cfg: dict, state: dict) -> str: @@ -304,198 +298,6 @@ def collect_proxmox_metrics(cfg: dict) -> dict | None: except Exception as e: return {"error": str(e)} -# ── Screenshot / Vision helpers ─────────────────────────────────────────────── - -def _take_screenshot(cmd_data: dict) -> dict: - """ - Attempts to capture a screenshot using available tools. - For headless servers, falls back to a rich text system snapshot. - Returns base64-encoded PNG and metadata. - """ - import base64, tempfile, shutil - - tmp = tempfile.mktemp(suffix=".png") - width = height = 0 - method = "unknown" - - # 1. Try scrot (X11 desktop) - if shutil.which("scrot") and os.environ.get("DISPLAY"): - try: - r = subprocess.run(["scrot", "-z", tmp], capture_output=True, timeout=10) - if r.returncode == 0 and os.path.exists(tmp): - method = "scrot" - except Exception: - pass - - # 2. Try import (ImageMagick X11) - if method == "unknown" and shutil.which("import") and os.environ.get("DISPLAY"): - try: - r = subprocess.run(["import", "-window", "root", tmp], capture_output=True, timeout=10) - if r.returncode == 0 and os.path.exists(tmp): - method = "import" - except Exception: - pass - - # 3. Try fbcat (Linux framebuffer — headless VMs with framebuffer) - if method == "unknown" and shutil.which("fbcat") and os.path.exists("/dev/fb0"): - try: - ppm = tempfile.mktemp(suffix=".ppm") - r = subprocess.run(["fbcat", "-s", "/dev/fb0"], stdout=open(ppm, "wb"), - stderr=subprocess.PIPE, timeout=10) - if r.returncode == 0 and shutil.which("convert"): - subprocess.run(["convert", ppm, tmp], capture_output=True, timeout=10) - os.unlink(ppm) - if os.path.exists(tmp): - method = "framebuffer" - except Exception: - pass - - # 4. Headless fallback: build a PNG system dashboard from text stats - if method == "unknown": - try: - result = _render_sysinfo_png(tmp) - if result: - method = "sysinfo_render" - except Exception: - pass - - if method == "unknown" or not os.path.exists(tmp): - # Last resort: return text snapshot only - snap = _sysinfo_snapshot() - snap["screenshot_available"] = False - snap["method"] = "text_only" - return snap - - # Read image - try: - with open(tmp, "rb") as f: - raw = f.read() - b64 = base64.b64encode(raw).decode() - fsize = len(raw) - os.unlink(tmp) - - # Try to get dimensions via file command - try: - r = subprocess.run(["identify", "-format", "%wx%h", tmp], - capture_output=True, text=True, timeout=5) - if "x" in r.stdout: - w, h = r.stdout.strip().split("x", 1) - width, height = int(w), int(h) - except Exception: - pass - - return { - "success": True, - "method": method, - "image_b64": b64, - "image_mime": "image/png", - "file_size": fsize, - "width": width, - "height": height, - "hostname": socket.gethostname(), - } - except Exception as e: - return {"success": False, "error": str(e), "method": method} - - -def _render_sysinfo_png(out_path: str) -> bool: - """Render a system info text snapshot as a PNG using ansi2image or ImageMagick.""" - import shutil - snap = _build_sysinfo_text() - # Try convert (ImageMagick) to render text → PNG - if shutil.which("convert"): - try: - r = subprocess.run([ - "convert", - "-size", "900x600", "xc:#0a0f14", - "-font", "Courier-New", - "-pointsize", "13", - "-fill", "#00d4ff", - "-annotate", "+20+30", snap[:3000], - out_path, - ], capture_output=True, timeout=15) - return r.returncode == 0 and os.path.exists(out_path) - except Exception: - pass - return False - - -def _build_sysinfo_text() -> str: - """Build a rich text system snapshot for headless machines.""" - lines = [f"JARVIS FIELD STATION — {socket.gethostname()}", - f"Timestamp: {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}", - "─" * 60] - try: - # CPU / mem / disk - with open("/proc/loadavg") as f: - load = f.read().split()[:3] - lines.append(f"Load avg: {' '.join(load)}") - except Exception: - pass - try: - with open("/proc/meminfo") as f: - minfo = {l.split(":")[0].strip(): int(l.split()[1]) for l in f if ":" in l} - total = minfo.get("MemTotal", 0) - avail = minfo.get("MemAvailable", 0) - used = total - avail - lines.append(f"Memory: {used//1024}MB used / {total//1024}MB total") - except Exception: - pass - try: - r = subprocess.run(["df", "-h", "/"], capture_output=True, text=True, timeout=5) - lines.append("Disk:\n" + r.stdout.strip()) - except Exception: - pass - try: - r = subprocess.run(["ps", "aux", "--sort=-%cpu"], capture_output=True, text=True, timeout=5) - lines.append("Top processes:\n" + "\n".join(r.stdout.splitlines()[1:8])) - except Exception: - pass - try: - r = subprocess.run(["ss", "-tlnp"], capture_output=True, text=True, timeout=5) - lines.append("Listening ports:\n" + r.stdout.strip()[:500]) - except Exception: - pass - return "\n".join(lines) - - -def _sysinfo_snapshot() -> dict: - """Return structured system snapshot (no image) for text-based analysis.""" - data = {"success": True, "hostname": socket.gethostname(), - "snapshot_type": "text", "screenshot_available": False} - try: - with open("/proc/loadavg") as f: - parts = f.read().split() - data["load_1m"], data["load_5m"], data["load_15m"] = parts[0], parts[1], parts[2] - except Exception: - pass - try: - with open("/proc/meminfo") as f: - m = {l.split(":")[0].strip(): int(l.split()[1]) - for l in f if ":" in l and len(l.split()) >= 2} - data["mem_total_mb"] = m.get("MemTotal", 0) // 1024 - data["mem_avail_mb"] = m.get("MemAvailable", 0) // 1024 - data["mem_used_mb"] = data["mem_total_mb"] - data["mem_avail_mb"] - except Exception: - pass - try: - r = subprocess.run(["df", "-h", "/"], capture_output=True, text=True, timeout=5) - data["disk"] = r.stdout.splitlines()[1] if r.stdout else "" - except Exception: - pass - try: - r = subprocess.run(["ps", "aux", "--sort=-%cpu"], capture_output=True, text=True, timeout=5) - data["top_procs"] = r.stdout.splitlines()[1:8] - except Exception: - pass - try: - r = subprocess.run(["ss", "-tlnp"], capture_output=True, text=True, timeout=5) - data["listening_ports"] = r.stdout.strip()[:800] - except Exception: - pass - return data - - # ── Command execution ───────────────────────────────────────────────────────── def execute_command(cmd: dict) -> dict: @@ -525,25 +327,17 @@ def execute_command(cmd: dict) -> dict: return {"success": r.returncode == 0, "output": r.stdout} elif cmd_type == "update": - _cfg = load_config() - updated = self_update(_cfg) + updated = self_update(cfg) return {"success": True, "updated": updated} elif cmd_type == "shell": - # Guard reads LOCAL config, not the server-supplied payload - _cfg = load_config() - if not _cfg.get("allow_shell_commands", False): - return {"success": False, "error": "Shell commands not enabled in agent config"} + # Only allow if explicitly enabled in config + if not cmd_data.get("allowed", False): + return {"success": False, "error": "Shell commands not enabled"} cmd_str = cmd_data.get("command", "") r = subprocess.run(cmd_str, shell=True, capture_output=True, text=True, timeout=30) return {"success": True, "stdout": r.stdout[:2000], "stderr": r.stderr[:500]} - elif cmd_type == "screenshot": - return _take_screenshot(cmd_data) - - elif cmd_type == "sysinfo": - return _sysinfo_snapshot() - else: return {"success": False, "error": f"Unknown command type: {cmd_type}"} @@ -565,18 +359,15 @@ def main(): poll_interval = int(cfg.get("poll_interval", 30)) heartbeat_every = int(cfg.get("heartbeat_every", 10)) - # Always re-register on startup to refresh capabilities, version, and IP. - # Server does an UPDATE when agent_id already exists, so api_key is preserved. + # Register if no API key yet api_key = state.get("api_key", "") - registered_key = register(cfg, state) - if registered_key: - api_key = registered_key - elif not api_key: - while not api_key: - api_key = register(cfg, state) - if not api_key: - print("[ERROR] Could not register with JARVIS. Retrying in 60s...", flush=True) - time.sleep(60) + if not api_key: + api_key = register(cfg, state) + if not api_key: + print("[ERROR] Could not register with JARVIS. Retrying in 60s...", flush=True) + time.sleep(60) + main() + return headers = {"X-Agent-Key": api_key} last_metrics = 0 @@ -633,9 +424,7 @@ def main(): # ── Self-update ──────────────────────────────────────────────────────────────── def self_update(cfg: dict) -> bool: - """Check JARVIS server for a newer version of this script. - Verifies SHA-256 hash from .sha256 before replacing.""" - import hashlib + """Check JARVIS server for a newer version of this script. If different, replace and restart.""" jarvis_url = cfg.get("jarvis_url", "").rstrip("/") default_update_url = f"{jarvis_url}/agent/jarvis-agent.py" if jarvis_url else "" update_url = cfg.get("update_url", default_update_url) @@ -643,37 +432,14 @@ def self_update(cfg: dict) -> bool: return False script_path = os.path.abspath(__file__) try: - # Download expected hash first - hash_url = update_url + ".sha256" - req_hash = urllib.request.Request(hash_url) - req_hash.add_header("User-Agent", "JARVIS-Agent/1.0") - if _host_header: - req_hash.add_header("Host", _host_header) - try: - with urllib.request.urlopen(req_hash, timeout=10) as resp: - expected_hash = resp.read().decode().strip().split()[0] - except Exception: - expected_hash = None - - # Download new script req = urllib.request.Request(update_url) req.add_header("User-Agent", "JARVIS-Agent/1.0") - if _host_header: - req.add_header("Host", _host_header) with urllib.request.urlopen(req, timeout=30) as resp: new_content = resp.read() - - # Verify hash if available — abort if mismatch - if expected_hash: - actual_hash = hashlib.sha256(new_content).hexdigest() - if actual_hash != expected_hash: - print(f"[JARVIS] Update hash mismatch (expected {expected_hash[:16]}… got {actual_hash[:16]}…) — aborting", flush=True) - return False - with open(script_path, "rb") as f: current = f.read() if new_content != current: - print(f"[JARVIS] Update verified — replacing {script_path} and restarting...", flush=True) + print(f"[JARVIS] Update available — replacing {script_path} and restarting...", flush=True) with open(script_path, "wb") as f: f.write(new_content) os.execv(sys.executable, [sys.executable] + sys.argv) diff --git a/public_html/agent/install-mac.sh b/public_html/agent/install-mac.sh index 0f8777b..9f5fda0 100644 --- a/public_html/agent/install-mac.sh +++ b/public_html/agent/install-mac.sh @@ -1,13 +1,13 @@ #!/bin/bash # JARVIS Agent Installer — macOS # Usage: bash install-mac.sh --jarvis-url https://jarvis.orbishosting.com --key YOUR_KEY +# Or one-liner: bash <(curl -sSL https://jarvis.orbishosting.com/agent/install-mac.sh) --key YOUR_KEY set -e -JARVIS_URL="" +JARVIS_URL="https://jarvis.orbishosting.com" REG_KEY="" INSTALL_DIR="$HOME/.jarvis-agent" -CONFIG_DIR="$HOME/.jarvis-agent" PLIST_PATH="$HOME/Library/LaunchAgents/com.jarvis.agent.plist" SERVICE_LABEL="com.jarvis.agent" @@ -19,59 +19,67 @@ while [[ $# -gt 0 ]]; do esac done -if [[ -z "$JARVIS_URL" ]]; then - read -rp "JARVIS URL (e.g. https://jarvis.orbishosting.com): " JARVIS_URL -fi if [[ -z "$REG_KEY" ]]; then read -rp "Registration key: " REG_KEY fi JARVIS_URL="${JARVIS_URL%/}" +echo "" +echo " ====================================" +echo " JARVIS Agent Installer v3.0 " +echo " ====================================" +echo "" + # Check for Python3 -PYTHON3=$(command -v python3 2>/dev/null || command -v /usr/bin/python3 2>/dev/null || "") +PYTHON3=$(command -v python3 2>/dev/null || "") if [[ -z "$PYTHON3" ]]; then echo "Python 3 is required. Install it with:" echo " brew install python3" echo " or download from https://www.python.org/downloads/" exit 1 fi -echo "Using Python: $PYTHON3 ($($PYTHON3 --version))" +echo "Using Python: $PYTHON3 ($($PYTHON3 --version 2>&1))" mkdir -p "$INSTALL_DIR" -# Download or copy agent script -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -if [[ -f "$SCRIPT_DIR/jarvis-agent.py" ]]; then - cp "$SCRIPT_DIR/jarvis-agent.py" "$INSTALL_DIR/jarvis-agent.py" +# Download macOS-native agent script +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || echo "")" +if [[ -f "$SCRIPT_DIR/jarvis-agent-mac.py" ]]; then + cp "$SCRIPT_DIR/jarvis-agent-mac.py" "$INSTALL_DIR/jarvis-agent.py" + echo "Copied agent from local directory." else - echo "Downloading agent..." - curl -sSL "https://raw.githubusercontent.com/myronblair/jarvis/master/agent/jarvis-agent.py" \ - -o "$INSTALL_DIR/jarvis-agent.py" + echo "Downloading macOS agent..." + curl -sSL "$JARVIS_URL/agent/jarvis-agent-mac.py" -o "$INSTALL_DIR/jarvis-agent.py" + echo "Downloaded." fi chmod +x "$INSTALL_DIR/jarvis-agent.py" HOSTNAME=$(hostname -f 2>/dev/null || hostname) +AGENT_ID="${HOSTNAME}_mac" -# Write config -if [[ ! -f "$CONFIG_DIR/config.json" ]]; then -cat > "$CONFIG_DIR/config.json" << JSONEOF +# Write config (preserve existing) +if [[ ! -f "$INSTALL_DIR/config.json" ]]; then +cat > "$INSTALL_DIR/config.json" << JSONEOF { "jarvis_url": "$JARVIS_URL", "registration_key": "$REG_KEY", "hostname": "$HOSTNAME", - "agent_type": "linux", + "agent_id": "$AGENT_ID", + "agent_type": "macos", "poll_interval": 30, "heartbeat_every": 10, - "watch_services": [] + "update_check_hours": 24, + "watch_services": [], + "allow_shell_commands": false } JSONEOF - chmod 600 "$CONFIG_DIR/config.json" + chmod 600 "$INSTALL_DIR/config.json" + echo "Config written to $INSTALL_DIR/config.json" +else + echo "Config already exists — preserving $INSTALL_DIR/config.json" fi -# Override state path in agent for macOS -STATE_PATH="$INSTALL_DIR/state.json" - # Write launchd plist mkdir -p "$HOME/Library/LaunchAgents" cat > "$PLIST_PATH" << PLISTEOF @@ -89,9 +97,9 @@ cat > "$PLIST_PATH" << PLISTEOF EnvironmentVariables JARVIS_CONFIG - $CONFIG_DIR/config.json + $INSTALL_DIR/config.json JARVIS_STATE - $STATE_PATH + $INSTALL_DIR/state.json RunAtLoad @@ -105,18 +113,23 @@ cat > "$PLIST_PATH" << PLISTEOF PLISTEOF -# Load the service +# Load/reload the service launchctl unload "$PLIST_PATH" 2>/dev/null || true launchctl load "$PLIST_PATH" sleep 2 -if launchctl list | grep -q "$SERVICE_LABEL"; then +if launchctl list 2>/dev/null | grep -q "$SERVICE_LABEL"; then echo "" - echo "✓ JARVIS Agent installed and running." - echo " View logs: tail -f $INSTALL_DIR/jarvis-agent.log" - echo " Config: $CONFIG_DIR/config.json" - echo " Stop: launchctl unload $PLIST_PATH" + echo " JARVIS Agent installed and running." + echo " Machine : $HOSTNAME ($AGENT_ID)" + echo " JARVIS : $JARVIS_URL" + echo " Logs : tail -f $INSTALL_DIR/jarvis-agent.log" + echo " Config : $INSTALL_DIR/config.json" + echo " Stop : launchctl unload $PLIST_PATH" + echo " Update : curl -sSL $JARVIS_URL/agent/install-mac.sh | bash -s -- --key $REG_KEY" else - echo "⚠ Agent installed but not running. Check logs:" + echo "" + echo " Agent installed but not detected as running. Check logs:" echo " tail -f $INSTALL_DIR/jarvis-agent.log" fi +echo "" diff --git a/public_html/agent/jarvis-agent-mac.py b/public_html/agent/jarvis-agent-mac.py new file mode 100644 index 0000000..6bd487a --- /dev/null +++ b/public_html/agent/jarvis-agent-mac.py @@ -0,0 +1,576 @@ +#!/usr/bin/env python3 +""" +JARVIS Agent for macOS — system monitor that reports metrics to JARVIS HUD. +Install: bash <(curl -sSL https://jarvis.orbishosting.com/agent/install-mac.sh) --key YOUR_KEY +Config: ~/.jarvis-agent/config.json (or $JARVIS_CONFIG) +Logs: ~/.jarvis-agent/jarvis-agent.log (or journalctl via launchd) +""" + +import json +import os +import platform +import re +import socket +import subprocess +import sys +import time +import urllib.request +import urllib.error +import hashlib +from datetime import datetime +from pathlib import Path + +AGENT_VERSION = "3.0" + +# Config/state paths — env vars let the launchd plist override them +_default_dir = Path.home() / ".jarvis-agent" +CONFIG_PATH = Path(os.environ.get("JARVIS_CONFIG", str(_default_dir / "config.json"))) +STATE_PATH = Path(os.environ.get("JARVIS_STATE", str(_default_dir / "state.json"))) +LOG_PATH = _default_dir / "jarvis-agent.log" + +# ── Logging ──────────────────────────────────────────────────────────────────── + +def log(msg: str): + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + line = f"[{ts}] {msg}" + print(line, flush=True) + try: + LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(LOG_PATH, "a") as f: + f.write(line + "\n") + except Exception: + pass + +# ── Config ───────────────────────────────────────────────────────────────────── + +def load_config() -> dict: + if not CONFIG_PATH.exists(): + print(f"[ERROR] Config not found at {CONFIG_PATH}. Run the installer first.") + sys.exit(1) + with open(CONFIG_PATH) as f: + return json.load(f) + +def load_state() -> dict: + if STATE_PATH.exists(): + with open(STATE_PATH) as f: + return json.load(f) + return {} + +def save_state(state: dict): + STATE_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(STATE_PATH, "w") as f: + json.dump(state, f, indent=2) + +# ── HTTP ─────────────────────────────────────────────────────────────────────── + +import ssl as _ssl + +def _make_ssl_ctx(verify: bool): + if not verify: + ctx = _ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = _ssl.CERT_NONE + return ctx + return None + +_host_header: str = "" + +def api_post(url: str, payload: dict, headers: dict = {}, timeout: int = 15, + ssl_verify: bool = True) -> dict: + body = json.dumps(payload).encode() + req = urllib.request.Request(url, data=body, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("User-Agent", "JARVIS-Agent-macOS/3.0") + if _host_header: + req.add_header("Host", _host_header) + for k, v in headers.items(): + req.add_header(k, v) + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + return {"error": f"HTTP {e.code}: {e.read().decode()[:200]}"} + except Exception as e: + return {"error": str(e)} + +def api_get(url: str, headers: dict = {}, timeout: int = 10, + ssl_verify: bool = True) -> dict: + req = urllib.request.Request(url) + req.add_header("User-Agent", "JARVIS-Agent-macOS/3.0") + if _host_header: + req.add_header("Host", _host_header) + for k, v in headers.items(): + req.add_header(k, v) + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: + return json.loads(resp.read().decode()) + except Exception as e: + return {"error": str(e)} + +# ── Metrics ──────────────────────────────────────────────────────────────────── + +def get_local_ip() -> str: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "unknown" + +_last_cpu_times = None + +def get_cpu_percent() -> float: + """Delta-based CPU % using top (two samples).""" + global _last_cpu_times + try: + # top -l 2 gives two snapshots; second line has the real delta + r = subprocess.run( + ["top", "-l", "2", "-s", "0", "-n", "0"], + capture_output=True, text=True, timeout=12 + ) + idle = None + for line in r.stdout.splitlines(): + if "CPU usage:" in line: + m = re.search(r"([\d.]+)%\s+idle", line) + if m: + idle = float(m.group(1)) + if idle is not None: + return round(100.0 - idle, 1) + except Exception: + pass + return 0.0 + +def get_memory() -> dict: + try: + # Total physical memory + r = subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=3) + total_bytes = int(r.stdout.strip()) + + # vm_stat for page counts; default page size on Apple Silicon and Intel = 4096 + page_size = 4096 + try: + ps_r = subprocess.run(["sysctl", "-n", "hw.pagesize"], capture_output=True, text=True, timeout=3) + page_size = int(ps_r.stdout.strip()) + except Exception: + pass + + vm_r = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=5) + pages = {} + for line in vm_r.stdout.splitlines(): + m = re.match(r"Pages\s+(.+?):\s+([\d]+)", line) + if m: + pages[m.group(1).strip().lower()] = int(m.group(2)) + + free_pages = pages.get("free", 0) + pages.get("speculative", 0) + # "available" = free + inactive (can be reclaimed) + avail_pages = free_pages + pages.get("inactive", 0) + used_pages = (total_bytes // page_size) - avail_pages + + total_mb = round(total_bytes / (1024 * 1024), 1) + used_mb = round(used_pages * page_size / (1024 * 1024), 1) + avail_mb = round(avail_pages * page_size / (1024 * 1024), 1) + used_mb = max(0, min(used_mb, total_mb)) + + return { + "total_mb": total_mb, + "used_mb": used_mb, + "free_mb": avail_mb, + "percent": round(used_mb / total_mb * 100, 1) if total_mb else 0, + } + except Exception: + return {} + +def get_disk() -> list: + disks = [] + try: + r = subprocess.run(["df", "-H", "-l"], capture_output=True, text=True, timeout=5) + for line in r.stdout.splitlines()[1:]: + parts = line.split() + if len(parts) >= 6: + mount = parts[5] + if not any(mount.startswith(x) for x in ["/dev", "/private/var/vm", "/System/Volumes/VM"]): + disks.append({ + "mount": mount, + "size": parts[1], + "used": parts[2], + "avail": parts[3], + "percent": parts[4].rstrip("%"), + }) + except Exception: + pass + return disks + +def get_uptime() -> dict: + try: + r = subprocess.run(["sysctl", "-n", "kern.boottime"], capture_output=True, text=True, timeout=3) + m = re.search(r"sec\s*=\s*(\d+)", r.stdout) + if m: + boot_ts = int(m.group(1)) + secs = time.time() - boot_ts + days = int(secs // 86400) + hours = int((secs % 86400) // 3600) + minutes = int((secs % 3600) // 60) + return {"seconds": int(secs), "days": days, "hours": hours, "minutes": minutes, + "human": f"{days}d {hours}h {minutes}m"} + except Exception: + pass + return {} + +def get_load() -> list: + try: + r = subprocess.run(["sysctl", "-n", "vm.loadavg"], capture_output=True, text=True, timeout=3) + # format: "{ 0.12 0.34 0.56 }" + nums = re.findall(r"[\d.]+", r.stdout) + if len(nums) >= 3: + return [float(nums[0]), float(nums[1]), float(nums[2])] + except Exception: + pass + return [0, 0, 0] + +def get_services(cfg: dict) -> list: + """Check macOS LaunchDaemon/LaunchAgent services via launchctl.""" + watch = cfg.get("watch_services", []) + if not watch: + return [] + statuses = [] + try: + r = subprocess.run(["launchctl", "list"], capture_output=True, text=True, timeout=5) + running = set() + for line in r.stdout.splitlines(): + parts = line.split() + if len(parts) >= 3: + running.add(parts[2]) + except Exception: + running = set() + for svc in watch: + status = "active" if any(svc in s for s in running) else "inactive" + statuses.append({"service": svc, "status": status}) + return statuses + +def detect_capabilities(cfg: dict) -> list: + import shutil + caps = ["metrics", "commands"] + if shutil.which("docker"): + caps.append("docker") + if shutil.which("ollama"): + caps.append("ollama") + # screencapture is always available on macOS + caps.append("screenshot") + caps.append("sysinfo") + return caps + +def collect_metrics(cfg: dict) -> dict: + return { + "hostname": cfg.get("hostname", socket.gethostname()), + "cpu_percent": get_cpu_percent(), + "memory": get_memory(), + "disk": get_disk(), + "uptime": get_uptime(), + "load": get_load(), + "services": get_services(cfg), + "platform": "macOS", + "os_version": platform.mac_ver()[0], + "timestamp": datetime.utcnow().isoformat() + "Z", + } + +# ── Registration ─────────────────────────────────────────────────────────────── + +def register(cfg: dict, state: dict) -> str: + hostname = cfg.get("hostname", socket.gethostname()) + agent_type = cfg.get("agent_type", "macos") + ip = get_local_ip() + capabilities = detect_capabilities(cfg) + agent_id = cfg.get("agent_id", f"{hostname}_mac") + ssl_verify = bool(cfg.get("ssl_verify", True)) + + log(f"Registering as '{agent_id}' ({agent_type}) from {ip}...") + + result = api_post( + f"{cfg['jarvis_url']}/api/agent/register", + {"hostname": hostname, "agent_type": agent_type, "ip_address": ip, + "capabilities": capabilities, "agent_id": agent_id}, + headers={"X-Registration-Key": cfg["registration_key"]}, + ssl_verify=ssl_verify, + ) + + if "error" in result: + log(f"[ERROR] Registration failed: {result['error']}") + return "" + + api_key = result.get("api_key", "") + if api_key: + state["api_key"] = api_key + state["agent_id"] = result.get("agent_id", agent_id) + save_state(state) + log(f"Registered. agent_id={state['agent_id']}") + return api_key + +# ── Screenshot ───────────────────────────────────────────────────────────────── + +def _take_screenshot(cmd_data: dict) -> dict: + import base64, tempfile, shutil + tmp = tempfile.mktemp(suffix=".png") + method = "unknown" + + # screencapture -x = no sound, always available on macOS + try: + r = subprocess.run(["screencapture", "-x", "-t", "png", tmp], + capture_output=True, timeout=15) + if r.returncode == 0 and os.path.exists(tmp): + method = "screencapture" + except Exception: + pass + + # Fallback: PIL + if method == "unknown": + try: + from PIL import ImageGrab + img = ImageGrab.grab() + img.save(tmp, "PNG") + method = "pil" + except Exception: + pass + + if method == "unknown" or not os.path.exists(tmp): + snap = _sysinfo_snapshot() + snap["screenshot_available"] = False + snap["method"] = "text_only" + return snap + + try: + with open(tmp, "rb") as f: + raw = f.read() + b64 = base64.b64encode(raw).decode() + try: + os.unlink(tmp) + except Exception: + pass + return { + "success": True, + "method": method, + "image_b64": b64, + "image_mime": "image/png", + "file_size": len(raw), + "hostname": socket.gethostname(), + } + except Exception as e: + return {"success": False, "error": str(e), "method": method} + +# ── Sysinfo ──────────────────────────────────────────────────────────────────── + +def _sysinfo_snapshot() -> dict: + data = {"success": True, "hostname": socket.gethostname(), + "snapshot_type": "text", "screenshot_available": False, "platform": "macOS"} + try: + mem = get_memory() + data["mem_total_mb"] = int(mem.get("total_mb", 0)) + data["mem_used_mb"] = int(mem.get("used_mb", 0)) + data["mem_avail_mb"] = int(mem.get("free_mb", 0)) + except Exception: + pass + try: + load = get_load() + data["load_1m"], data["load_5m"], data["load_15m"] = load[0], load[1], load[2] + except Exception: + pass + try: + r = subprocess.run(["df", "-H", "/"], capture_output=True, text=True, timeout=5) + data["disk"] = r.stdout.splitlines()[1] if r.stdout else "" + except Exception: + pass + try: + r = subprocess.run(["ps", "aux", "-r"], capture_output=True, text=True, timeout=5) + data["top_procs"] = r.stdout.splitlines()[1:8] + except Exception: + pass + try: + r = subprocess.run(["netstat", "-an", "-p", "tcp"], capture_output=True, text=True, timeout=5) + listening = [l for l in r.stdout.splitlines() if "LISTEN" in l] + data["listening_ports"] = "\n".join(listening[:20]) + except Exception: + pass + return data + +# ── Command execution ────────────────────────────────────────────────────────── + +def execute_command(cmd: dict) -> dict: + cmd_type = cmd.get("command_type", "") + cmd_data = cmd.get("command_data", {}) + try: + if cmd_type == "ping": + host = cmd_data.get("host", "8.8.8.8") + r = subprocess.run(["ping", "-c", "3", "-W", "2000", host], + capture_output=True, text=True, timeout=15) + return {"success": r.returncode == 0, "output": r.stdout} + + elif cmd_type == "update": + _cfg = load_config() + updated = self_update(_cfg) + return {"success": True, "updated": updated} + + elif cmd_type == "shell": + _cfg = load_config() + if not _cfg.get("allow_shell_commands", False): + return {"success": False, "error": "Shell commands not enabled in agent config"} + cmd_str = cmd_data.get("command", "") + r = subprocess.run(cmd_str, shell=True, capture_output=True, text=True, timeout=30) + return {"success": True, "stdout": r.stdout[:2000], "stderr": r.stderr[:500]} + + elif cmd_type == "restart_service": + svc = cmd_data.get("service", "") + if not svc or "/" in svc: + return {"success": False, "error": "Invalid service name"} + r = subprocess.run(["launchctl", "kickstart", "-k", f"system/{svc}"], + capture_output=True, text=True, timeout=15) + if r.returncode != 0: + r = subprocess.run(["brew", "services", "restart", svc], + capture_output=True, text=True, timeout=15) + return {"success": r.returncode == 0, "stdout": r.stdout, "stderr": r.stderr} + + elif cmd_type == "get_logs": + svc = cmd_data.get("service", "") + lines = min(int(cmd_data.get("lines", 50)), 200) + r = subprocess.run(["log", "show", "--predicate", f'process == "{svc}"', + "--last", "1h", "--style", "compact"], + capture_output=True, text=True, timeout=15) + output = "\n".join(r.stdout.splitlines()[-lines:]) + return {"success": True, "output": output} + + elif cmd_type == "screenshot": + return _take_screenshot(cmd_data) + + elif cmd_type == "sysinfo": + return _sysinfo_snapshot() + + else: + return {"success": False, "error": f"Unknown command type: {cmd_type}"} + + except subprocess.TimeoutExpired: + return {"success": False, "error": "Command timed out"} + except Exception as e: + return {"success": False, "error": str(e)} + +# ── Self-update ──────────────────────────────────────────────────────────────── + +def self_update(cfg: dict) -> bool: + jarvis_url = cfg.get("jarvis_url", "").rstrip("/") + default_update_url = f"{jarvis_url}/agent/jarvis-agent-mac.py" if jarvis_url else "" + update_url = cfg.get("update_url", default_update_url) + if not update_url: + return False + script_path = os.path.abspath(__file__) + ssl_verify = bool(cfg.get("ssl_verify", True)) + try: + # Download expected hash + hash_url = update_url + ".sha256" + req_hash = urllib.request.Request(hash_url) + req_hash.add_header("User-Agent", "JARVIS-Agent-macOS/3.0") + if _host_header: + req_hash.add_header("Host", _host_header) + expected_hash = None + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req_hash, timeout=10, context=ctx) as resp: + expected_hash = resp.read().decode().strip().split()[0] + except Exception: + pass + + # Download new script + req = urllib.request.Request(update_url) + req.add_header("User-Agent", "JARVIS-Agent-macOS/3.0") + if _host_header: + req.add_header("Host", _host_header) + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=30, context=ctx) as resp: + new_content = resp.read() + + if expected_hash: + actual_hash = hashlib.sha256(new_content).hexdigest() + if actual_hash != expected_hash: + log(f"Update hash mismatch (expected {expected_hash[:16]}… got {actual_hash[:16]}…) — aborting") + return False + + with open(script_path, "rb") as f: + current = f.read() + if new_content != current: + log(f"Update verified — replacing {script_path} and restarting...") + with open(script_path, "wb") as f: + f.write(new_content) + os.execv(sys.executable, [sys.executable] + sys.argv) + return True + return False + except Exception as e: + log(f"Self-update check failed: {e}") + return False + +# ── Main loop ────────────────────────────────────────────────────────────────── + +def main(): + global _host_header + cfg = load_config() + state = load_state() + + jarvis_url = cfg["jarvis_url"].rstrip("/") + ssl_verify = bool(cfg.get("ssl_verify", True)) + _host_header = cfg.get("host_header", "") + poll_interval = int(cfg.get("poll_interval", 30)) + heartbeat_every = int(cfg.get("heartbeat_every", 10)) + update_interval = int(cfg.get("update_check_hours", 24)) * 3600 + + # Always re-register on startup to refresh capabilities, version, IP + api_key = state.get("api_key", "") + registered_key = register(cfg, state) + if registered_key: + api_key = registered_key + elif not api_key: + while not api_key: + api_key = register(cfg, state) + if not api_key: + log("[ERROR] Could not register with JARVIS. Retrying in 60s...") + time.sleep(60) + + headers = {"X-Agent-Key": api_key} + last_metrics = 0 + last_update_chk = 0 + + log(f"Agent v{AGENT_VERSION} (macOS) running. Polling {jarvis_url} every {heartbeat_every}s.") + + while True: + tick_start = time.time() + now = tick_start + try: + hb = api_post(f"{jarvis_url}/api/agent/heartbeat", {}, headers, ssl_verify=ssl_verify) + if "error" in hb: + log(f"[WARN] Heartbeat failed: {hb['error']}") + else: + for cmd in hb.get("commands", []): + log(f"[CMD] Executing: {cmd['command_type']}") + result = execute_command(cmd) + api_post(f"{jarvis_url}/api/agent/command_result", + {"command_id": cmd["id"], "success": result.get("success", False), "result": result}, + headers, ssl_verify=ssl_verify) + + # Self-update check + if now - last_update_chk >= update_interval: + last_update_chk = now + self_update(cfg) + + # Push metrics + if now - last_metrics >= poll_interval: + metrics = collect_metrics(cfg) + api_post(f"{jarvis_url}/api/agent/metrics", + {"type": "system", "data": metrics}, headers, ssl_verify=ssl_verify) + last_metrics = now + + except Exception as e: + log(f"[ERROR] Loop error: {e}") + + time.sleep(heartbeat_every) + + +if __name__ == "__main__": + main() diff --git a/public_html/agent/jarvis-agent-mac.py.sha256 b/public_html/agent/jarvis-agent-mac.py.sha256 new file mode 100644 index 0000000..dc36921 --- /dev/null +++ b/public_html/agent/jarvis-agent-mac.py.sha256 @@ -0,0 +1 @@ +d0aeaab1686f788c9d46ffeaac57a42e3e82b80c2d7fac2be443c05cf70c87be jarvis-agent-mac.py diff --git a/public_html/agent/jarvis-agent-windows.py b/public_html/agent/jarvis-agent-windows.py index efa3688..d66655c 100644 --- a/public_html/agent/jarvis-agent-windows.py +++ b/public_html/agent/jarvis-agent-windows.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 """ JARVIS Agent for Windows — system monitor that reports metrics to JARVIS HUD. -Install via PowerShell: iwr https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex -Config: C:\\ProgramData\\jarvis-agent\\config.json -Logs: C:\\ProgramData\\jarvis-agent\\jarvis-agent.log +Install via PowerShell (as Admin): irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex +Config: C:\ProgramData\jarvis-agent\config.json +Logs: C:\ProgramData\jarvis-agent\jarvis-agent.log """ import json @@ -15,20 +15,18 @@ import sys import time import urllib.request import urllib.error -import uuid -from datetime import datetime, timezone +import hashlib +from datetime import datetime from pathlib import Path INSTALL_DIR = Path(r"C:\ProgramData\jarvis-agent") CONFIG_PATH = INSTALL_DIR / "config.json" STATE_PATH = INSTALL_DIR / "state.json" LOG_PATH = INSTALL_DIR / "jarvis-agent.log" -AGENT_VERSION = "2.2" +AGENT_VERSION = "3.0" # ── Logging ──────────────────────────────────────────────────────────────────── -_log_file = None - def log(msg: str): ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") line = f"[{ts}] {msg}" @@ -45,7 +43,7 @@ def load_config() -> dict: if not CONFIG_PATH.exists(): print(f"[ERROR] Config not found at {CONFIG_PATH}. Run the installer first.") sys.exit(1) - with open(CONFIG_PATH, encoding="utf-8-sig") as f: + with open(CONFIG_PATH, encoding="utf-8") as f: return json.load(f) def load_state() -> dict: @@ -78,7 +76,7 @@ def api_post(url: str, payload: dict, headers: dict = {}, timeout: int = 15, body = json.dumps(payload).encode() req = urllib.request.Request(url, data=body, method="POST") req.add_header("Content-Type", "application/json") - req.add_header("User-Agent", "JARVIS-Agent-Windows/1.0") + req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0") if _host_header: req.add_header("Host", _host_header) for k, v in headers.items(): @@ -95,7 +93,7 @@ def api_post(url: str, payload: dict, headers: dict = {}, timeout: int = 15, def api_get(url: str, headers: dict = {}, timeout: int = 10, ssl_verify: bool = True) -> dict: req = urllib.request.Request(url) - req.add_header("User-Agent", "JARVIS-Agent-Windows/1.0") + req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0") if _host_header: req.add_header("Host", _host_header) for k, v in headers.items(): @@ -107,10 +105,9 @@ def api_get(url: str, headers: dict = {}, timeout: int = 10, except Exception as e: return {"error": str(e)} -# ── Metrics ──────────────────────────────────────────────────────────────────── +# ── PowerShell helper ────────────────────────────────────────────────────────── def _ps(script: str, timeout: int = 8) -> str: - """Run a PowerShell one-liner and return stdout.""" try: r = subprocess.run( ["powershell", "-NoProfile", "-NonInteractive", "-Command", script], @@ -120,6 +117,8 @@ def _ps(script: str, timeout: int = 8) -> str: except Exception: return "" +# ── Metrics ──────────────────────────────────────────────────────────────────── + def get_local_ip() -> str: try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) @@ -130,10 +129,7 @@ def get_local_ip() -> str: except Exception: return "unknown" -_last_cpu_counters = None - def get_cpu_percent() -> float: - global _last_cpu_counters try: out = _ps("(Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average") return round(float(out), 1) @@ -196,7 +192,7 @@ def get_uptime() -> dict: return {} def get_services(cfg: dict) -> list: - watch = cfg.get("watch_services", ["WinDefend", "wuauserv", "Spooler"]) + watch = cfg.get("watch_services", ["WinDefend", "Spooler"]) statuses = [] for svc in watch: try: @@ -209,8 +205,12 @@ def get_services(cfg: dict) -> list: def detect_capabilities(cfg: dict) -> list: caps = ["metrics", "commands"] + import shutil if Path(r"C:\Program Files\Docker\Docker\Docker Desktop.exe").exists(): caps.append("docker") + # Screenshot via PIL or PowerShell .NET (always available on Windows) + caps.append("screenshot") + caps.append("sysinfo") return caps def collect_metrics(cfg: dict) -> dict: @@ -223,20 +223,21 @@ def collect_metrics(cfg: dict) -> dict: "load": [0, 0, 0], "services": get_services(cfg), "platform": "Windows", - "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "os_version": platform.version(), + "timestamp": datetime.utcnow().isoformat() + "Z", } # ── Registration ─────────────────────────────────────────────────────────────── def register(cfg: dict, state: dict) -> str: hostname = cfg.get("hostname", socket.gethostname().lower()) - agent_type = cfg.get("agent_type", "linux") + agent_type = cfg.get("agent_type", "windows") ip = get_local_ip() capabilities = detect_capabilities(cfg) - agent_id = cfg.get("agent_id", f"{hostname}_{hostname[:8]}") + agent_id = cfg.get("agent_id", f"{hostname}_windows") ssl_verify = bool(cfg.get("ssl_verify", True)) - log(f"[JARVIS] Registering as '{agent_id}' from {ip}...") + log(f"Registering as '{agent_id}' ({agent_type}) from {ip}...") result = api_post( f"{cfg['jarvis_url']}/api/agent/register", @@ -255,9 +256,160 @@ def register(cfg: dict, state: dict) -> str: state["api_key"] = api_key state["agent_id"] = result.get("agent_id", agent_id) save_state(state) - log(f"[JARVIS] Registered. agent_id={state['agent_id']}") + log(f"Registered. agent_id={state['agent_id']}") return api_key +# ── Screenshot ───────────────────────────────────────────────────────────────── + +def _take_screenshot(cmd_data: dict) -> dict: + import base64, tempfile + tmp = str(INSTALL_DIR / "screenshot_tmp.png") + method = "unknown" + + # Try PIL/Pillow first + try: + from PIL import ImageGrab + img = ImageGrab.grab(all_screens=True) + img.save(tmp, "PNG") + method = "pil" + except ImportError: + pass + except Exception: + pass + + # Fallback: PowerShell .NET screenshot (no extra packages needed) + if method == "unknown": + ps_script = ( + "Add-Type -AssemblyName System.Windows.Forms,System.Drawing; " + "$s=[System.Windows.Forms.SystemInformation]::VirtualScreen; " + "$bmp=New-Object System.Drawing.Bitmap($s.Width,$s.Height); " + "$g=[System.Drawing.Graphics]::FromImage($bmp); " + "$g.CopyFromScreen($s.Location,[System.Drawing.Point]::Empty,$s.Size); " + f"$bmp.Save('{tmp}'); $g.Dispose(); $bmp.Dispose()" + ) + try: + r = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script], + capture_output=True, timeout=15 + ) + if r.returncode == 0 and os.path.exists(tmp): + method = "powershell" + except Exception: + pass + + if method == "unknown" or not os.path.exists(tmp): + return _sysinfo_snapshot() + + try: + with open(tmp, "rb") as f: + raw = f.read() + b64 = base64.b64encode(raw).decode() + try: + os.unlink(tmp) + except Exception: + pass + return { + "success": True, + "method": method, + "image_b64": b64, + "image_mime": "image/png", + "file_size": len(raw), + "hostname": socket.gethostname(), + } + except Exception as e: + return {"success": False, "error": str(e), "method": method} + +# ── Sysinfo ──────────────────────────────────────────────────────────────────── + +def _sysinfo_snapshot() -> dict: + data = {"success": True, "hostname": socket.gethostname(), + "snapshot_type": "text", "screenshot_available": False, "platform": "Windows"} + try: + mem = get_memory() + data["mem_total_mb"] = int(mem.get("total_mb", 0)) + data["mem_used_mb"] = int(mem.get("used_mb", 0)) + data["mem_avail_mb"] = int(mem.get("free_mb", 0)) + except Exception: + pass + try: + data["cpu_percent"] = get_cpu_percent() + except Exception: + pass + try: + data["disk"] = get_disk() + except Exception: + pass + try: + ut = get_uptime() + data["uptime"] = ut.get("human", "") + except Exception: + pass + try: + out = _ps("Get-Process | Sort-Object CPU -Descending | Select-Object -First 8 Name,CPU,WorkingSet | ConvertTo-Json") + data["top_procs"] = json.loads(out) if out else [] + except Exception: + pass + try: + out = _ps("netstat -an | findstr LISTENING | head -20") + data["listening_ports"] = out[:800] + except Exception: + pass + return data + +# ── Self-update ──────────────────────────────────────────────────────────────── + +def self_update(cfg: dict) -> bool: + jarvis_url = cfg.get("jarvis_url", "").rstrip("/") + default_update_url = f"{jarvis_url}/agent/jarvis-agent-windows.py" if jarvis_url else "" + update_url = cfg.get("update_url", default_update_url) + if not update_url: + return False + script_path = os.path.abspath(__file__) + ssl_verify = bool(cfg.get("ssl_verify", True)) + try: + # Download expected hash + hash_url = update_url + ".sha256" + req_hash = urllib.request.Request(hash_url) + req_hash.add_header("User-Agent", "JARVIS-Agent-Windows/3.0") + if _host_header: + req_hash.add_header("Host", _host_header) + expected_hash = None + try: + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req_hash, timeout=10, context=ctx) as resp: + expected_hash = resp.read().decode().strip().split()[0] + except Exception: + pass + + # Download new script + req = urllib.request.Request(update_url) + req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0") + if _host_header: + req.add_header("Host", _host_header) + ctx = _make_ssl_ctx(ssl_verify) + with urllib.request.urlopen(req, timeout=30, context=ctx) as resp: + new_content = resp.read() + + # Verify hash + if expected_hash: + actual_hash = hashlib.sha256(new_content).hexdigest() + if actual_hash != expected_hash: + log(f"Update hash mismatch (expected {expected_hash[:16]}… got {actual_hash[:16]}…) — aborting") + return False + + with open(script_path, "rb") as f: + current = f.read() + if new_content != current: + log(f"Update verified — replacing {script_path} and restarting...") + with open(script_path, "wb") as f: + f.write(new_content) + os.execv(sys.executable, [sys.executable] + sys.argv) + return True + return False + except Exception as e: + log(f"Self-update check failed: {e}") + return False + # ── Command execution ────────────────────────────────────────────────────────── def execute_command(cmd: dict, cfg: dict) -> dict: @@ -271,16 +423,40 @@ def execute_command(cmd: dict, cfg: dict) -> dict: elif cmd_type == "update": log("[CMD] Self-update requested") - return {"success": True, "message": "Windows agent self-update not implemented"} + updated = self_update(cfg) + return {"success": True, "updated": updated} elif cmd_type == "shell": - if not cmd_data.get("allowed", False): - return {"success": False, "error": "Shell commands not enabled"} + # Guard reads LOCAL config — never trust server-supplied flags + _cfg = load_config() + if not _cfg.get("allow_shell_commands", False): + return {"success": False, "error": "Shell commands not enabled in agent config"} cmd_str = cmd_data.get("command", "") - r = subprocess.run(["powershell", "-NoProfile", "-Command", cmd_str], + r = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd_str], capture_output=True, text=True, timeout=30) return {"success": True, "stdout": r.stdout[:2000], "stderr": r.stderr[:500]} + elif cmd_type == "restart_service": + svc = cmd_data.get("service", "") + if not svc: + return {"success": False, "error": "No service specified"} + out = _ps(f"Restart-Service -Name '{svc}' -Force -ErrorAction Stop; 'ok'") + return {"success": "ok" in out.lower(), "output": out} + + elif cmd_type == "get_logs": + svc = cmd_data.get("service", "") + lines = min(int(cmd_data.get("lines", 50)), 200) + if not svc: + return {"success": False, "error": "No service specified"} + out = _ps(f"Get-EventLog -LogName Application -Source '{svc}' -Newest {lines} -ErrorAction SilentlyContinue | Format-List TimeGenerated,EntryType,Message | Out-String") + return {"success": True, "output": out[:3000]} + + elif cmd_type == "screenshot": + return _take_screenshot(cmd_data) + + elif cmd_type == "sysinfo": + return _sysinfo_snapshot() + else: return {"success": False, "error": f"Unknown command type: {cmd_type}"} @@ -302,19 +478,25 @@ def main(): _host_header = cfg.get("host_header", "") poll_interval = int(cfg.get("poll_interval", 30)) heartbeat_every = int(cfg.get("heartbeat_every", 10)) + update_interval = int(cfg.get("update_check_hours", 24)) * 3600 + # Always re-register on startup to refresh capabilities, version, IP api_key = state.get("api_key", "") - if not api_key: - api_key = register(cfg, state) - if not api_key: - log("[ERROR] Could not register with JARVIS. Retrying in 60s...") - time.sleep(60) - main() - return + registered_key = register(cfg, state) + if registered_key: + api_key = registered_key + elif not api_key: + while not api_key: + api_key = register(cfg, state) + if not api_key: + log("[ERROR] Could not register with JARVIS. Retrying in 60s...") + time.sleep(60) - headers = {"X-Agent-Key": api_key} - last_metrics = 0 - log(f"[JARVIS] Agent v{AGENT_VERSION} (Windows) running. Connecting to {jarvis_url} every {heartbeat_every}s.") + headers = {"X-Agent-Key": api_key} + last_metrics = 0 + last_update_chk = 0 + + log(f"Agent v{AGENT_VERSION} (Windows) running. Polling {jarvis_url} every {heartbeat_every}s.") while True: now = time.time() @@ -330,17 +512,23 @@ def main(): {"command_id": cmd["id"], "success": result.get("success", False), "result": result}, headers, ssl_verify=ssl_verify) + # Self-update check + if now - last_update_chk >= update_interval: + last_update_chk = now + self_update(cfg) + + # Push metrics if now - last_metrics >= poll_interval: metrics = collect_metrics(cfg) - r = api_post(f"{jarvis_url}/api/agent/metrics", - {"system": metrics}, headers, ssl_verify=ssl_verify) - if "error" not in r: - last_metrics = now + api_post(f"{jarvis_url}/api/agent/metrics", + {"type": "system", "data": metrics}, headers, ssl_verify=ssl_verify) + last_metrics = now except Exception as e: log(f"[ERROR] Loop error: {e}") time.sleep(heartbeat_every) + if __name__ == "__main__": main() diff --git a/public_html/agent/jarvis-agent-windows.py.sha256 b/public_html/agent/jarvis-agent-windows.py.sha256 new file mode 100644 index 0000000..f4a888c --- /dev/null +++ b/public_html/agent/jarvis-agent-windows.py.sha256 @@ -0,0 +1 @@ +feadcb033426838f0d9e87df933fc3cd6b09b0d74eea7dc697b29a12421a1f2d jarvis-agent-windows.py diff --git a/public_html/agent/jarvis-agent.py b/public_html/agent/jarvis-agent.py index 8dcfe1e..4494c7a 100644 --- a/public_html/agent/jarvis-agent.py +++ b/public_html/agent/jarvis-agent.py @@ -23,7 +23,7 @@ from pathlib import Path CONFIG_PATH = "/etc/jarvis-agent/config.json" STATE_PATH = "/var/lib/jarvis-agent/state.json" -AGENT_VERSION = "3.0" # Phase 4: screenshot + sysinfo commands +AGENT_VERSION = "3.1" # ── Config helpers ──────────────────────────────────────────────────────────── @@ -119,12 +119,6 @@ def detect_capabilities(cfg: dict) -> list: # Check for Home Assistant if os.path.exists("/etc/homeassistant") or os.path.exists("/config/configuration.yaml"): caps.append("homeassistant") - # Phase 4: screenshot capability - import shutil as _shutil - if (_shutil.which("scrot") or _shutil.which("import") or - _shutil.which("fbcat") or _shutil.which("convert")): - caps.append("screenshot") - caps.append("sysinfo") return caps def register(cfg: dict, state: dict) -> str: @@ -304,198 +298,6 @@ def collect_proxmox_metrics(cfg: dict) -> dict | None: except Exception as e: return {"error": str(e)} -# ── Screenshot / Vision helpers ─────────────────────────────────────────────── - -def _take_screenshot(cmd_data: dict) -> dict: - """ - Attempts to capture a screenshot using available tools. - For headless servers, falls back to a rich text system snapshot. - Returns base64-encoded PNG and metadata. - """ - import base64, tempfile, shutil - - tmp = tempfile.mktemp(suffix=".png") - width = height = 0 - method = "unknown" - - # 1. Try scrot (X11 desktop) - if shutil.which("scrot") and os.environ.get("DISPLAY"): - try: - r = subprocess.run(["scrot", "-z", tmp], capture_output=True, timeout=10) - if r.returncode == 0 and os.path.exists(tmp): - method = "scrot" - except Exception: - pass - - # 2. Try import (ImageMagick X11) - if method == "unknown" and shutil.which("import") and os.environ.get("DISPLAY"): - try: - r = subprocess.run(["import", "-window", "root", tmp], capture_output=True, timeout=10) - if r.returncode == 0 and os.path.exists(tmp): - method = "import" - except Exception: - pass - - # 3. Try fbcat (Linux framebuffer — headless VMs with framebuffer) - if method == "unknown" and shutil.which("fbcat") and os.path.exists("/dev/fb0"): - try: - ppm = tempfile.mktemp(suffix=".ppm") - r = subprocess.run(["fbcat", "-s", "/dev/fb0"], stdout=open(ppm, "wb"), - stderr=subprocess.PIPE, timeout=10) - if r.returncode == 0 and shutil.which("convert"): - subprocess.run(["convert", ppm, tmp], capture_output=True, timeout=10) - os.unlink(ppm) - if os.path.exists(tmp): - method = "framebuffer" - except Exception: - pass - - # 4. Headless fallback: build a PNG system dashboard from text stats - if method == "unknown": - try: - result = _render_sysinfo_png(tmp) - if result: - method = "sysinfo_render" - except Exception: - pass - - if method == "unknown" or not os.path.exists(tmp): - # Last resort: return text snapshot only - snap = _sysinfo_snapshot() - snap["screenshot_available"] = False - snap["method"] = "text_only" - return snap - - # Read image - try: - with open(tmp, "rb") as f: - raw = f.read() - b64 = base64.b64encode(raw).decode() - fsize = len(raw) - os.unlink(tmp) - - # Try to get dimensions via file command - try: - r = subprocess.run(["identify", "-format", "%wx%h", tmp], - capture_output=True, text=True, timeout=5) - if "x" in r.stdout: - w, h = r.stdout.strip().split("x", 1) - width, height = int(w), int(h) - except Exception: - pass - - return { - "success": True, - "method": method, - "image_b64": b64, - "image_mime": "image/png", - "file_size": fsize, - "width": width, - "height": height, - "hostname": socket.gethostname(), - } - except Exception as e: - return {"success": False, "error": str(e), "method": method} - - -def _render_sysinfo_png(out_path: str) -> bool: - """Render a system info text snapshot as a PNG using ansi2image or ImageMagick.""" - import shutil - snap = _build_sysinfo_text() - # Try convert (ImageMagick) to render text → PNG - if shutil.which("convert"): - try: - r = subprocess.run([ - "convert", - "-size", "900x600", "xc:#0a0f14", - "-font", "Courier-New", - "-pointsize", "13", - "-fill", "#00d4ff", - "-annotate", "+20+30", snap[:3000], - out_path, - ], capture_output=True, timeout=15) - return r.returncode == 0 and os.path.exists(out_path) - except Exception: - pass - return False - - -def _build_sysinfo_text() -> str: - """Build a rich text system snapshot for headless machines.""" - lines = [f"JARVIS FIELD STATION — {socket.gethostname()}", - f"Timestamp: {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}", - "─" * 60] - try: - # CPU / mem / disk - with open("/proc/loadavg") as f: - load = f.read().split()[:3] - lines.append(f"Load avg: {' '.join(load)}") - except Exception: - pass - try: - with open("/proc/meminfo") as f: - minfo = {l.split(":")[0].strip(): int(l.split()[1]) for l in f if ":" in l} - total = minfo.get("MemTotal", 0) - avail = minfo.get("MemAvailable", 0) - used = total - avail - lines.append(f"Memory: {used//1024}MB used / {total//1024}MB total") - except Exception: - pass - try: - r = subprocess.run(["df", "-h", "/"], capture_output=True, text=True, timeout=5) - lines.append("Disk:\n" + r.stdout.strip()) - except Exception: - pass - try: - r = subprocess.run(["ps", "aux", "--sort=-%cpu"], capture_output=True, text=True, timeout=5) - lines.append("Top processes:\n" + "\n".join(r.stdout.splitlines()[1:8])) - except Exception: - pass - try: - r = subprocess.run(["ss", "-tlnp"], capture_output=True, text=True, timeout=5) - lines.append("Listening ports:\n" + r.stdout.strip()[:500]) - except Exception: - pass - return "\n".join(lines) - - -def _sysinfo_snapshot() -> dict: - """Return structured system snapshot (no image) for text-based analysis.""" - data = {"success": True, "hostname": socket.gethostname(), - "snapshot_type": "text", "screenshot_available": False} - try: - with open("/proc/loadavg") as f: - parts = f.read().split() - data["load_1m"], data["load_5m"], data["load_15m"] = parts[0], parts[1], parts[2] - except Exception: - pass - try: - with open("/proc/meminfo") as f: - m = {l.split(":")[0].strip(): int(l.split()[1]) - for l in f if ":" in l and len(l.split()) >= 2} - data["mem_total_mb"] = m.get("MemTotal", 0) // 1024 - data["mem_avail_mb"] = m.get("MemAvailable", 0) // 1024 - data["mem_used_mb"] = data["mem_total_mb"] - data["mem_avail_mb"] - except Exception: - pass - try: - r = subprocess.run(["df", "-h", "/"], capture_output=True, text=True, timeout=5) - data["disk"] = r.stdout.splitlines()[1] if r.stdout else "" - except Exception: - pass - try: - r = subprocess.run(["ps", "aux", "--sort=-%cpu"], capture_output=True, text=True, timeout=5) - data["top_procs"] = r.stdout.splitlines()[1:8] - except Exception: - pass - try: - r = subprocess.run(["ss", "-tlnp"], capture_output=True, text=True, timeout=5) - data["listening_ports"] = r.stdout.strip()[:800] - except Exception: - pass - return data - - # ── Command execution ───────────────────────────────────────────────────────── def execute_command(cmd: dict) -> dict: @@ -525,25 +327,17 @@ def execute_command(cmd: dict) -> dict: return {"success": r.returncode == 0, "output": r.stdout} elif cmd_type == "update": - _cfg = load_config() - updated = self_update(_cfg) + updated = self_update(cfg) return {"success": True, "updated": updated} elif cmd_type == "shell": - # Guard reads LOCAL config, not the server-supplied payload - _cfg = load_config() - if not _cfg.get("allow_shell_commands", False): - return {"success": False, "error": "Shell commands not enabled in agent config"} + # Only allow if explicitly enabled in config + if not cmd_data.get("allowed", False): + return {"success": False, "error": "Shell commands not enabled"} cmd_str = cmd_data.get("command", "") r = subprocess.run(cmd_str, shell=True, capture_output=True, text=True, timeout=30) return {"success": True, "stdout": r.stdout[:2000], "stderr": r.stderr[:500]} - elif cmd_type == "screenshot": - return _take_screenshot(cmd_data) - - elif cmd_type == "sysinfo": - return _sysinfo_snapshot() - else: return {"success": False, "error": f"Unknown command type: {cmd_type}"} @@ -565,18 +359,15 @@ def main(): poll_interval = int(cfg.get("poll_interval", 30)) heartbeat_every = int(cfg.get("heartbeat_every", 10)) - # Always re-register on startup to refresh capabilities, version, and IP. - # Server does an UPDATE when agent_id already exists, so api_key is preserved. + # Register if no API key yet api_key = state.get("api_key", "") - registered_key = register(cfg, state) - if registered_key: - api_key = registered_key - elif not api_key: - while not api_key: - api_key = register(cfg, state) - if not api_key: - print("[ERROR] Could not register with JARVIS. Retrying in 60s...", flush=True) - time.sleep(60) + if not api_key: + api_key = register(cfg, state) + if not api_key: + print("[ERROR] Could not register with JARVIS. Retrying in 60s...", flush=True) + time.sleep(60) + main() + return headers = {"X-Agent-Key": api_key} last_metrics = 0 @@ -633,9 +424,7 @@ def main(): # ── Self-update ──────────────────────────────────────────────────────────────── def self_update(cfg: dict) -> bool: - """Check JARVIS server for a newer version of this script. - Verifies SHA-256 hash from .sha256 before replacing.""" - import hashlib + """Check JARVIS server for a newer version of this script. If different, replace and restart.""" jarvis_url = cfg.get("jarvis_url", "").rstrip("/") default_update_url = f"{jarvis_url}/agent/jarvis-agent.py" if jarvis_url else "" update_url = cfg.get("update_url", default_update_url) @@ -643,37 +432,14 @@ def self_update(cfg: dict) -> bool: return False script_path = os.path.abspath(__file__) try: - # Download expected hash first - hash_url = update_url + ".sha256" - req_hash = urllib.request.Request(hash_url) - req_hash.add_header("User-Agent", "JARVIS-Agent/1.0") - if _host_header: - req_hash.add_header("Host", _host_header) - try: - with urllib.request.urlopen(req_hash, timeout=10) as resp: - expected_hash = resp.read().decode().strip().split()[0] - except Exception: - expected_hash = None - - # Download new script req = urllib.request.Request(update_url) req.add_header("User-Agent", "JARVIS-Agent/1.0") - if _host_header: - req.add_header("Host", _host_header) with urllib.request.urlopen(req, timeout=30) as resp: new_content = resp.read() - - # Verify hash if available — abort if mismatch - if expected_hash: - actual_hash = hashlib.sha256(new_content).hexdigest() - if actual_hash != expected_hash: - print(f"[JARVIS] Update hash mismatch (expected {expected_hash[:16]}… got {actual_hash[:16]}…) — aborting", flush=True) - return False - with open(script_path, "rb") as f: current = f.read() if new_content != current: - print(f"[JARVIS] Update verified — replacing {script_path} and restarting...", flush=True) + print(f"[JARVIS] Update available — replacing {script_path} and restarting...", flush=True) with open(script_path, "wb") as f: f.write(new_content) os.execv(sys.executable, [sys.executable] + sys.argv) diff --git a/public_html/agent/jarvis-agent.py.sha256 b/public_html/agent/jarvis-agent.py.sha256 index 4cde60a..b7f4083 100644 --- a/public_html/agent/jarvis-agent.py.sha256 +++ b/public_html/agent/jarvis-agent.py.sha256 @@ -1 +1 @@ -aa05371d8610a5fd89f397b7feda90fd93acc169a5c910c2969b6319a189da25 /home/jarvis.orbishosting.com/public_html/agent/jarvis-agent.py +bccfeed43d7fbcd5f002656e866432b8aaa5035bc797f855727f50147f609427 jarvis-agent.py