Files
jarvis/agent/jarvis-agent-windows.py
myron b19ce85d84 Windows agent: run as background Windows Service (Win 8.1+)
Replace the scheduled-task approach (required user to stay logged in) with a
proper Windows Service using pywin32. The service runs as LocalSystem, starts
at boot, and auto-restarts on failure — no PowerShell window needed.

Agent changes (jarvis-agent-windows.py):
- Add Windows Service class via pywin32 (JarvisAgentService)
- Cleanly handles SvcStop by setting a threading.Event
- main() loop uses _stop_event.wait() instead of time.sleep() so stop is immediate
- self_update() signals the stop event when running as a service (SCM restarts it)
- __main__ block dispatches to SCM entry point or HandleCommandLine (install/stop/remove)
- Falls back to direct run if pywin32 not installed (for debugging)

Installer changes (install-windows.ps1):
- pip install pywin32 + postinstall (registers service runner DLLs)
- Python search prefers system-wide install (accessible by LocalSystem)
- Downloads Python 3.11 directly from python.org for Win 8.1 machines without winget
- Removes legacy JARVIS-Agent scheduled task if present
- Registers JARVISAgent service with --startup auto
- Configures sc.exe failure recovery (restart at 5s/10s/30s)
- Updated management commands in summary (Start-Service, Stop-Service, etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 12:11:38 +00:00

608 lines
23 KiB
Python

#!/usr/bin/env python3
"""
JARVIS Agent for Windows — system monitor that reports metrics to JARVIS HUD.
Runs as a Windows Service (Win 8.1+) via pywin32.
Install (run PowerShell as Admin):
irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex
Service management:
python jarvis-agent-windows.py --startup auto install # register service
python jarvis-agent-windows.py start # start
python jarvis-agent-windows.py stop # stop
python jarvis-agent-windows.py remove # uninstall
python jarvis-agent-windows.py debug # run in console (for testing)
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 threading
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.1"
# Set by the service wrapper so self_update knows to stop instead of exec
_is_service = False
_stop_event = threading.Event()
# ── 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, "version": AGENT_VERSION, "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)
if _is_service:
# Signal the main loop to exit; SCM failure-recovery will restart us
log("Running as service — stopping for SCM-managed restart after update.")
_stop_event.set()
else:
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...")
if _stop_event.wait(60):
return
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 not _stop_event.is_set():
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}")
if _stop_event.wait(heartbeat_every):
break # service stop was requested
log("Agent stopped.")
# ── Windows Service wrapper ────────────────────────────────────────────────────
try:
import win32serviceutil
import win32service
import win32event
import servicemanager
_HAS_WIN32 = True
except ImportError:
_HAS_WIN32 = False
if _HAS_WIN32:
class JarvisAgentService(win32serviceutil.ServiceFramework):
_svc_name_ = "JARVISAgent"
_svc_display_name_ = "JARVIS AI Agent"
_svc_description_ = "JARVIS system monitoring and AI agent — reports metrics to JARVIS HUD"
def __init__(self, args):
win32serviceutil.ServiceFramework.__init__(self, args)
self._svc_stop_event = win32event.CreateEvent(None, 0, 0, None)
def SvcStop(self):
self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
_stop_event.set()
win32event.SetEvent(self._svc_stop_event)
def SvcDoRun(self):
global _is_service
_is_service = True
servicemanager.LogMsg(
servicemanager.EVENTLOG_INFORMATION_TYPE,
servicemanager.PYS_SERVICE_STARTED,
(self._svc_name_, ""),
)
main()
if __name__ == "__main__":
if _HAS_WIN32:
if len(sys.argv) == 1:
# No args — SCM is starting us as a service
servicemanager.Initialize()
servicemanager.PrepareToHostSingle(JarvisAgentService)
servicemanager.StartServiceCtrlDispatcher()
else:
# install / start / stop / remove / debug
win32serviceutil.HandleCommandLine(JarvisAgentService)
else:
# pywin32 not available — run directly (useful for testing)
main()