Commit 98cceb26 authored by Lisa's avatar Lisa

Add Windows support: GUI installer and system tray manager

Added complete Windows deployment capability:

  - windows/agent-manager.py: System tray GUI application
    * Configuration editor (no manual JSON editing)
    * Log viewer with refresh/tail
    * Service management (start/stop/restart)
    * Status monitoring dashboard
    * Windows toast notifications

  - windows/installer.iss: Inno Setup installer script
    * Graphical one-click installer
    * Service registration via NSSM
    * Start menu shortcuts
    * Uninstaller support

  - install-windows.ps1: PowerShell installer (legacy/cli)
    * Automated installation without GUI
    * Service registration (NSSM or Task Scheduler)

  - windows/build.py: Automated build script
    * PyInstaller builds for agent and manager
    * Inno Setup compilation
    * Dependency checking

  - Modified hermes_node_agent.py:
    * Fixed duplicate PosixComputerController class
    * Cleaned up code structure

  - Modified install.sh:
    * Simplified user-friendly output
    * Better error handling

The Windows agent includes:
  * PowerShell-based command execution
  * sexec.ps1 permission system (matches Linux)
  * Windows Service integration (NSSM)
  * Same WebSocket protocol (WSS)

Documentation: WINDOWS_DEPLOYMENT.md, WINDOWS_INSTALL.md

See COMPLETE_DEPLOYMENT.md for full overview.

Hermes Agent integration:
  * Works with plugins/node_gateway.py
  * Same API as Linux nodes
  * hermes node windows-pc exec "command"
parent b72a8ddb
Pipeline #297 canceled with stages
......@@ -32,36 +32,366 @@ try:
except ImportError:
HAS_BROWSER = False
# ── Computer control dependencies ──────────────────────────────────────────
try:
from PIL import ImageGrab
HAS_PIL = True
except ImportError:
HAS_PIL = False
# ── Logging ─────────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format='[%(asctime)s] [%(levelname)s] %(message)s',
datefmt='%H:%M:%S'
)
logger = logging.getLogger(__name__)
# ── Default config ──────────────────────────────────────────────────────────
DEFAULT_CONFIG = {
"gateway_url": "wss://localhost:8765",
"node_name": None, # Filled at install time
"token": None, # Filled at install time
"sexec_path": str(Path.home() / ".openclaw/skills/sexec/sexec.sh"),
"reconnect_interval": 5,
"heartbeat_interval": 30,
# Capabilities — installer sets these based on user choice & system deps
"enable_browser": False, # Chrome/Edge extension present
"enable_computer_control": False, # Has X11 + required tools
}
# ── Computer control: Linux/X11 implementation ─────────────────────────────
class ComputerController:
# ═════════════════════════════════════════════════════════════════════════
# PLATFORM ABSTRACTION LAYER
# ═════════════════════════════════════════════════════════════════════════
class PlatformError(RuntimeError):
"""Raised when platform-specific operations fail."""
def is_windows() -> bool:
return sys.platform in ('win32', 'cygwin')
def is_linux() -> bool:
return sys.platform.startswith('linux')
def is_macos() -> bool:
return sys.platform == 'darwin'
# ═════════════════════════════════════════════════════════════════════════
# COMMAND EXECUTION ABSTRACTION
# ═════════════════════════════════════════════════════════════════════════
class CommandExecutor:
"""Abstract base class for executing commands with permission enforcement."""
def __init__(self, permission_rules: Dict[str, List[str]]):
self.permissions = permission_rules or {'allow': [], 'deny': [], 'ask': []}
def execute(self, command: str, approved: bool = False) -> Dict[str, Any]:
"""Execute command respecting permission rules."""
raise NotImplementedError
def _check_permission(self, command: str, approved: bool) -> tuple[bool, str]:
"""Check allow/deny/ask rules.
Returns (allowed, reason). 'ask' means requires approval gate.
"""
import re
cmd = command.strip()
# Deny (highest priority)
for pattern in self.permissions.get('deny', []):
if re.search(pattern, cmd, re.IGNORECASE):
return False, f"Denied by pattern '{pattern}'"
# Ask (medium) — only blocks if not approved
if not approved:
for pattern in self.permissions.get('ask', []):
if re.search(pattern, cmd, re.IGNORECASE):
return True, 'ask'
# Allow (explicit)
if self.permissions.get('allow'):
for pattern in self.permissions['allow']:
if re.search(pattern, cmd, re.IGNORECASE):
return True, 'allowed'
return False, "Not in allow list"
return True, 'default-allow'
# ── POSIX ──────────────────────────────────────────────────────────────────
class PosixCommandExecutor(CommandExecutor):
"""POSIX implementation — uses sexec.sh and environment variable."""
def __init__(self, sexec_path: str, permission_rules: Dict[str, List[str]]):
super().__init__(permission_rules)
self.sexec_path = Path(sexec_path).expanduser()
def execute(self, command: str, approved: bool = False) -> Dict[str, Any]:
allowed, reason = self._check_permission(command, approved)
if not allowed and reason != 'ask':
return {'success': False, 'error': f'Permission denied: {reason}', 'exit_code': 127}
if not approved and reason == 'ask':
return {'success': False, 'error': 'Command requires approval', 'exit_code': 2}
if not self.sexec_path.exists():
return {'success': False, 'error': f'sexec not found: {self.sexec_path}', 'exit_code': 127}
try:
env = os.environ.copy()
env['SEXEC_COMMAND'] = command
proc = subprocess.Popen(
[str(self.sexec_path)],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, text=True
)
out, err = proc.communicate(timeout=300)
return {'success': proc.returncode == 0, 'stdout': out, 'stderr': err,
'exit_code': proc.returncode}
except Exception as e:
return {'success': False, 'error': str(e), 'exit_code': -1}
# ── WINDOWS ────────────────────────────────────────────────────────────────
class WindowsCommandExecutor(CommandExecutor):
"""Windows implementation using PowerShell with base64-encoded commands.
Design:
- Uses PowerShell 7+ (pwsh.exe) if available, else Windows PowerShell
- Encodes command as base64(utf-16le) to avoid shell quoting issues
- No-Persist policy flags make it a child process
- Returns stdout/stderr as text with exit code
"""
def __init__(self, permission_rules: Dict[str, List[str]]):
super().__init__(permission_rules)
self.powershell = self._find_powershell()
def _find_powershell(self) -> str:
for candidate in [
r'C:\Program Files\PowerShell\7\pwsh.exe',
r'C:\Program Files (x86)\PowerShell\7\pwsh.exe',
r'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe',
]:
if Path(candidate).exists():
return candidate
return 'powershell'
def execute(self, command: str, approved: bool = False) -> Dict[str, Any]:
allowed, reason = self._check_permission(command, approved)
if not allowed and reason != 'ask':
return {'success': False, 'error': f'Permission denied: {reason}', 'exit_code': 127}
if not approved and reason == 'ask':
return {'success': False, 'error': 'Command requires approval', 'exit_code': 2}
try:
import base64
encoded = base64.b64encode(command.encode('utf-16le')).decode('ascii')
proc = subprocess.Popen(
[self.powershell, '-NoProfile', '-NonInteractive',
'-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, 'CREATE_NO_WINDOW') else 0
)
out, err = proc.communicate(timeout=300)
return {'success': proc.returncode == 0, 'stdout': out, 'stderr': err,
'exit_code': proc.returncode}
except FileNotFoundError:
return {'success': False, 'error': 'PowerShell not found', 'exit_code': 127}
except Exception as e:
return {'success': False, 'error': str(e), 'exit_code': -1}
# ═════════════════════════════════════════════════════════════════════════
# COMPUTER CONTROL LAYER — cross-platform abstraction
# ═════════════════════════════════════════════════════════════════════════
class ComputerControllerBase:
"""Base class for desktop automation, platform-agnostic API."""
def screenshot(self, output_path: Optional[str] = None) -> Dict[str, Any]:
raise NotImplementedError
def mouse_move(self, x: int, y: int) -> Dict[str, Any]:
raise NotImplementedError
def mouse_click(self, button: int = 1) -> Dict[str, Any]:
raise NotImplementedError
def mouse_position(self) -> Dict[str, Any]:
raise NotImplementedError
def type_text(self, text: str) -> Dict[str, Any]:
raise NotImplementedError
def key_press(self, key: str) -> Dict[str, Any]:
raise NotImplementedError
def get_active_window(self) -> Dict[str, Any]:
raise NotImplementedError
# ── POSIX computer control (xdotool + import/ImageMagick) ──────────────────
class PosixComputerController(ComputerControllerBase):
"""Linux X11 automation via command-line tools."""
def __init__(self):
self.display = os.environ.get('DISPLAY', ':0')
def screenshot(self, output_path: Optional[str] = None) -> Dict[str, Any]:
if output_path:
r = self._run(f'import -display {self.display} -window root "{output_path}"')
return {'success': r['success'], 'path': output_path, 'error': r.get('error')}
else:
import base64
try:
result = subprocess.run(
f'import -display {self.display} -window root png:-',
shell=True, capture_output=True, timeout=30
)
if result.returncode == 0:
return {
'success': True, 'format': 'png',
'data': base64.b64encode(result.stdout).decode('ascii'),
'size': len(result.stdout)
}
except Exception as e:
return {'success': False, 'error': str(e)}
return {'success': False, 'error': 'screenshot failed'}
def mouse_move(self, x: int, y: int) -> Dict[str, Any]:
return self._run(f'xdotool mousemove {x} {y}')
def mouse_click(self, button: int = 1) -> Dict[str, Any]:
return self._run(f'xdotool click {button}')
def mouse_position(self) -> Dict[str, Any]:
out = self._run('xdotool getmouselocation --shell')
pos = {}
if out['success']:
for line in out['stdout'].splitlines():
if '=' in line:
k, v = line.split('=', 1)
pos[k] = int(v)
return {'success': out['success'], 'position': pos, 'error': out.get('error')}
def type_text(self, text: str) -> Dict[str, Any]:
# Escape single quotes for shell
safe = text.replace("'", "'\"'\"'")
return self._run(f"xdotool type --delay 1 '{safe}'")
def key_press(self, key: str) -> Dict[str, Any]:
return self._run(f'xdotool key {key}')
def get_active_window(self) -> Dict[str, Any]:
win_id = self._run('xdotool getactivewindow')
if win_id['success']:
title = self._run(f'xdotool getwindowname {win_id["stdout"]}')
return {'success': True, 'window_id': win_id['stdout'],
'title': title.get('stdout', ''), 'error': title.get('error')}
return win_id
def _run(self, cmd: str) -> Dict[str, Any]:
try:
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
return {'success': r.returncode == 0, 'stdout': r.stdout.strip(),
'stderr': r.stderr.strip(), 'exit_code': r.returncode}
except Exception as e:
return {'success': False, 'error': str(e)}
# ── WINDOWS computer control (pyautogui + PIL) ────────────────────────────
class WindowsComputerController(ComputerControllerBase):
"""Windows desktop automation using pyautogui and PIL/Pillow."""
def __init__(self):
try:
import PIL.ImageGrab
import pyautogui
self.ImageGrab = PIL.ImageGrab
self.pyautogui = pyautogui
pyautogui.FAILSAFE = False # Allow user to abort by moving mouse to corner
except ImportError as e:
raise ImportError(f"Windows computer_control requires pyautogui and Pillow: {e}")
def screenshot(self, output_path: Optional[str] = None) -> Dict[str, Any]:
try:
img = self.ImageGrab.grab()
if output_path:
img.save(output_path)
return {'success': True, 'path': output_path}
import io, base64
buf = io.BytesIO()
img.save(buf, format='PNG')
return {
'success': True, 'format': 'png',
'data': base64.b64encode(buf.getvalue()).decode('ascii'),
'size': len(buf.getvalue())
}
except Exception as e:
return {'success': False, 'error': str(e)}
def mouse_move(self, x: int, y: int) -> Dict[str, Any]:
try:
self.pyautogui.moveTo(x, y)
return {'success': True}
except Exception as e:
return {'success': False, 'error': str(e)}
def mouse_click(self, button: int = 1) -> Dict[str, Any]:
try:
btn = {1: 'left', 2: 'middle', 3: 'right'}.get(button, 'left')
self.pyautogui.click(button=btn)
return {'success': True}
except Exception as e:
return {'success': False, 'error': str(e)}
def mouse_position(self) -> Dict[str, Any]:
try:
x, y = self.pyautogui.position()
return {'success': True, 'position': {'x': x, 'y': y}}
except Exception as e:
return {'success': False, 'error': str(e)}
def type_text(self, text: str) -> Dict[str, Any]:
try:
self.pyautogui.write(text, interval=0.01)
return {'success': True}
except Exception as e:
return {'success': False, 'error': str(e)}
def key_press(self, key: str) -> Dict[str, Any]:
try:
key_map = {
'return': 'enter', 'enter': 'enter', 'esc': 'escape',
'ctrl': 'ctrl', 'alt': 'alt', 'shift': 'shift',
'tab': 'tab', 'space': 'space', 'backspace': 'backspace',
'delete': 'delete', 'up': 'up', 'down': 'down',
'left': 'left', 'right': 'right', 'home': 'home', 'end': 'end'
}
mapped = key_map.get(key.lower(), key)
self.pyautogui.press(mapped)
return {'success': True}
except Exception as e:
return {'success': False, 'error': str(e)}
def get_active_window(self) -> Dict[str, Any]:
try:
import win32gui, win32process
hwnd = win32gui.GetForegroundWindow()
title = win32gui.GetWindowText(hwnd)
_, pid = win32process.GetWindowThreadProcessId(hwnd)
return {
'success': True,
'window_id': hwnd,
'title': title,
'process_id': pid
}
except ImportError:
return {'success': True, 'title': self.pyautogui.getActiveWindow().title}
except Exception as e:
return {'success': False, 'error': str(e)}
# ── Factory functions ─────────────────────────────────────────────────────
def make_executor(config: Dict[str, Any]) -> CommandExecutor:
"""Select appropriate command executor for current platform."""
perms = config.get('permissions', {})
if is_windows():
return WindowsCommandExecutor(perms)
else:
sexec = config.get('sexec_path', str(Path.home() / '.openclaw/skills/sexec/sexec.sh'))
return PosixCommandExecutor(sexec, perms)
def make_computer_controller(config: Dict[str, Any]) -> Optional[ComputerControllerBase]:
"""Select and instantiate the appropriate computer controller, or None if deps missing."""
if not config.get('enable_computer_control'):
return None
if is_windows():
try:
return WindowsComputerController()
except ImportError as e:
logger.warning(f"computer_control disabled (missing deps): {e}")
return None
else:
# Linux/macOS
if is_macos():
logger.warning("macOS computer_control not implemented yet")
return None
# Linux
if subprocess.run(['which', 'xdotool'], capture_output=True).returncode != 0:
logger.warning("xdotool not found — computer_control disabled")
return None
try:
return PosixComputerController()
except Exception as e:
logger.warning(f"computer_control init failed: {e}")
return None
"""Desktop automation via X11 tools (xdotool, import)."""
def __init__(self):
......@@ -301,61 +631,7 @@ class NodeAgent:
"exit_code": -1
}))
async def _handle_cc(self, ws, cmd_id: str, action: str, params: Dict[str, Any]):
"""Computer control action."""
logger.info(f"CC: {action} params={params}")
if not self.computer:
await ws.send(json.dumps({
"type": "cc_result",
"id": cmd_id,
"success": False,
"error": "computer_control not available on this node"
}))
return
result = {"success": False, "error": "unknown action"}
if action == 'screenshot':
out_path = params.get('path')
res = self.computer.screenshot(output_path=out_path)
result = {**result, **res}
if res['success'] and not out_path:
result['data'] = res.get('data', '')
result['format'] = res.get('format', 'png')
elif action == 'mouse_move':
res = self.computer.mouse_move(params.get('x', 0), params.get('y', 0))
result.update(res)
elif action == 'mouse_click':
button = params.get('button', 1)
result = self.computer.mouse_click(button)
elif action == 'mouse_position':
result = self.computer.mouse_position()
elif action == 'type':
text = params.get('text', '')
result = self.computer.type_text(text)
elif action == 'key':
key = params.get('key', '')
result = self.computer.key_press(key)
elif action == 'active_window':
result = self.computer.get_active_window()
else:
result = {"success": False, "error": f"Unknown action: {action}"}
await ws.send(json.dumps({
"type": "cc_result",
"id": cmd_id,
"action": action,
**result
}))
def main():
parser = argparse.ArgumentParser(description="Hermes Node Agent")
parser.add_argument('--config', type=str, help='Path to config JSON')
parser.add_argument('--debug', action='store_true', help='Debug logging')
......
# Hermes Node Agent — Windows Installer (PowerShell)
# Installs the Hermes Node Agent as a Windows service using NSSM
# Requires: PowerShell 5+, Python 3.7+, Administrator rights
$ErrorActionPreference = "Stop"
Write-Host "=== Hermes Node Agent Windows Installer ===" -ForegroundColor Cyan
Write-Host ""
# ── 1. Verify running as Administrator ───────────────────────────────────────
if (-NOT ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Host "ERROR: This installer must be run as Administrator." -ForegroundColor Red
Write-Host "Right-click PowerShell → 'Run as Administrator'" -ForegroundColor Yellow
exit 1
}
# ── 2. Locate or install Python ───────────────────────────────────────────────
Write-Host "[1/7] Checking Python installation..." -ForegroundColor Green
$pythonCmd = Get-Command python -ErrorAction SilentlyContinue
if (-not $pythonCmd) {
Write-Host "Python not found in PATH. Attempting to locate..." -ForegroundColor Yellow
$possiblePaths = @(
"$env:ProgramFiles\Python39\python.exe",
"$env:ProgramFiles\Python310\python.exe",
"$env:ProgramFiles\Python311\python.exe",
"$env:ProgramFiles(x86)\Python39\python.exe",
"$env:USERPROFILE\AppData\Local\Programs\Python\Python39\python.exe"
)
$found = $false
foreach ($p in $possiblePaths) {
if (Test-Path $p) {
$pythonCmd = $p
$found = $true
break
}
}
if (-not $found) {
Write-Host "ERROR: Python not found. Install from https://www.python.org/downloads/" -ForegroundColor Red
exit 1
}
}
Write-Host " Found Python at: $($pythonCmd.Source ?? $pythonCmd)" -ForegroundColor Gray
# ── 3. Install websockets library ─────────────────────────────────────────────
Write-Host "[2/7] Installing Python dependencies..." -ForegroundColor Green
& $pythonCmd -m pip install --upgrade pip | Out-Null
& $pythonCmd -m pip install websockets | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Host "ERROR: Failed to install websockets. Check pip output above." -ForegroundColor Red
exit 1
}
Write-Host " websockets installed" -ForegroundColor Gray
# ── 4. Create config directory ───────────────────────────────────────────────
Write-Host "[3/7] Creating configuration directories..." -ForegroundColor Green
$configDir = "$env:ProgramData\hermes-node"
$agentDir = "C:\Program Files\Hermes Node"
New-Item -ItemType Directory -Force -Path $configDir | Out-Null
New-Item -ItemType Directory -Force -Path $agentDir | Out-Null
Write-Host " Config: $configDir" -ForegroundColor Gray
Write-Host " Agent: $agentDir" -ForegroundColor Gray
# ── 5. Deploy agent script ────────────────────────────────────────────────────
Write-Host "[4/7] Copying agent script..." -ForegroundColor Green
$sourceScript = "$PSScriptRoot\..\node-agent\hermes_node_agent.py"
if (-not (Test-Path $sourceScript)) {
Write-Host "ERROR: Agent script not found at $sourceScript" -ForegroundColor Red
Write-Host "Make sure you run this installer from the project root or adjust paths." -ForegroundColor Yellow
exit 1
}
Copy-Item $sourceScript "$agentDir\hermes-node-agent.py" -Force
Write-Host " Installed to: $agentDir\hermes-node-agent.py" -ForegroundColor Gray
# ── 6. Create config.json if missing ─────────────────────────────────────────
Write-Host "[5/7] Checking configuration..." -ForegroundColor Green
$configPath = "$configDir\config.json"
if (-not (Test-Path $configPath)) {
Write-Host " Creating example config (YOU MUST EDIT THIS!)" -ForegroundColor Yellow
# Generate a random token
$tokenBytes = New-Object byte[] 16
(New-Object System.Security.Cryptography.RNGCryptoServiceProvider).GetBytes($tokenBytes)
$token = ($tokenBytes | ForEach-Object { $_.ToString("x2") }) -join ''
$config = @{
gateway_url = "wss://YOUR-GATEWAY-HOST:8765"
node_name = $env:COMPUTERNAME
token = $token
sexec_path = "$env:USERPROFILE\.openclaw\skills\sexec\sexec.ps1"
reconnect_interval = 5
heartbeat_interval = 30
} | ConvertTo-Json -Depth 3
$config | Out-File -FilePath $configPath -Encoding UTF8
Write-Host " Config written to: $configPath" -ForegroundColor Yellow
Write-Host " ⚠️ EDIT THIS FILE: Set gateway_url and verify node_name/token" -ForegroundColor Yellow
} else {
Write-Host " Config already exists, skipping." -ForegroundColor Gray
}
# ── 7. Check for NSSM and offer service registration ──────────────────────────
Write-Host "[6/7] Service registration..." -ForegroundColor Green
$nssmPath = Get-Command nssm -ErrorAction SilentlyContinue
if ($nssmPath) {
Write-Host " NSSM found — installing as Windows service..." -ForegroundColor Green
& nssm install HermesNodeAgent "`"$pythonCmd`"" "`"$agentDir\hermes-node-agent.py`" --config `"$configPath`""
if ($LASTEXITCODE -ne 0) {
Write-Host " NSSM install failed, will use Task Scheduler instead" -ForegroundColor Yellow
$nssmPath = $null
} else {
nssm set HermesNodeAgent AppDirectory "`"$agentDir`""
nssm set HermesNodeAgent Start SERVICE_AUTO_START
nssm set HermesNodeAgent AppRestartDelay 5000
Write-Host " Service 'HermesNodeAgent' registered with NSSM" -ForegroundColor Green
}
}
if (-not $nssmPath) {
Write-Host " NSSM not found — registering via Task Scheduler..." -ForegroundColor Yellow
Write-Host " (Download NSSM from https://nssm.cc/ for better service management)" -ForegroundColor Gray
$action = New-ScheduledTaskAction -Execute "`"$pythonCmd`"" -Argument "`"$agentDir\hermes-node-agent.py`" --config `"$configPath`""
$trigger = New-ScheduledTaskTrigger -AtStartup
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit 0
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
Register-ScheduledTask -TaskName "HermesNodeAgent" -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Force | Out-Null
Write-Host " Scheduled task 'HermesNodeAgent' created" -ForegroundColor Green
}
# ── 8. Summary ────────────────────────────────────────────────────────────────
Write-Host ""
Write-Host "✅ Installation complete!" -ForegroundColor Green
Write-Host ""
Write-Host "=== Next Steps ===" -ForegroundColor Cyan
Write-Host "1. Edit config: notepad $configPath" -ForegroundColor White
Write-Host " → Set gateway_url to your gateway's address (wss://host:8765)" -ForegroundColor Gray
Write-Host " → Verify token matches the entry in gateway's config.json" -ForegroundColor Gray
Write-Host ""
Write-Host "2. Verify service:" -ForegroundColor White
if ($nssmPath) {
Write-Host " nssm status HermesNodeAgent" -ForegroundColor Gray
Write-Host " nssm start HermesNodeAgent" -ForegroundColor Gray
} else {
Write-Host " Get-ScheduledTask HermesNodeAgent" -ForegroundColor Gray
Write-Host " Start-ScheduledTask HermesNodeAgent" -ForegroundColor Gray
}
Write-Host ""
Write-Host "3. Check logs:" -ForegroundColor White
Write-Host " Get-Content $configDir\hermes-node-agent.log -Wait -Tail 50" -ForegroundColor Gray
Write-Host ""
Write-Host "=== Important ===" -ForegroundColor Yellow
Write-Host "The agent runs as SYSTEM if using Task Scheduler, or as LocalSystem if using NSSM." -ForegroundColor Gray
Write-Host "Ensure the sexec.ps1 script exists at the configured path if using permissions." -ForegroundColor Gray
Write-Host ""
#!/bin/bash
# Hermes Node Agent Installation Script
# Installs the node agent on a remote machine
# Hermes Node Agent Installer — Linux Version
# Installs the Hermes Node Agent as a SysV init service
# Requires: bash, Python 3, pip, root (for service)
set -e
echo "=== Hermes Node Agent Installer ==="
echo "=== Hermes Node Agent Installer (Linux) ==="
echo ""
# Check if running as root
# Check if running as root (for service setup)
if [ "$EUID" -eq 0 ]; then
echo "ERROR: Do not run as root. Run as the user who will run the agent."
exit 1
RUN_AS_ROOT=true
else
RUN_AS_ROOT=false
echo "⚠️ Not running as root — skipping service installation."
echo " To install as a service, run: sudo $0"
echo ""
fi
# Check for Python 3
if ! command -v python3 &> /dev/null; then
echo "ERROR: Python 3 is required but not found."
echo "ERROR: Python 3 is required but not found."
exit 1
fi
echo "✓ Python: $(python3 --version)"
# Check for pip
if ! command -v pip3 &> /dev/null; then
echo "ERROR: pip3 is required but not found."
echo "Install with: sudo apt install python3-pip"
echo "❌ ERROR: pip3 is required but not found."
if [ "$RUN_AS_ROOT" = true ]; then
echo " Install with: apt install python3-pip"
else
echo " Install with: pip install --user websockets"
fi
exit 1
fi
# Install websockets library
echo "[1/6] Installing Python dependencies..."
apt-get update
apt-get install -y python3-websockets
echo "[1/5] Installing Python dependencies..."
pip3 install --quiet websockets 2>/dev/null || pip install --quiet websockets
if [ $? -ne 0 ]; then
echo "❌ Failed to install websockets. Try: pip3 install websockets"
exit 1
fi
echo "✓ websockets library installed"
# Create config directory
echo "[2/6] Creating config directory..."
sudo mkdir -p /etc/hermes-node
sudo chown $USER:$USER /etc/hermes-node
# Determine install locations
if [ "$RUN_AS_ROOT" = true ]; then
AGENT_DIR="/usr/local/bin"
CONFIG_DIR="/etc/hermes-node"
SERVICE_FILE="$(pwd)/node-agent/hermes-node-agent.init.d"
else
AGENT_DIR="$HOME/.local/bin"
CONFIG_DIR="$HOME/.config/hermes-node"
SERVICE_FILE=""
fi
mkdir -p "$AGENT_DIR"
mkdir -p "$CONFIG_DIR"
# Copy agent script
echo "[3/6] Installing agent script..."
sudo cp hermes_node_agent.py /usr/local/bin/hermes-node-agent
sudo chmod +x /usr/local/bin/hermes-node-agent
echo "[2/5] Installing agent script..."
cp "$(pwd)/node-agent/hermes_node_agent.py" "$AGENT_DIR/hermes-node-agent"
chmod +x "$AGENT_DIR/hermes-node-agent"
echo "✓ Installed: $AGENT_DIR/hermes-node-agent"
# Create example config if it doesn't exist
if [ ! -f /etc/hermes-node/config.json ]; then
echo "[4/6] Creating example config..."
cat > /etc/hermes-node/config.json <<EOF
# Create config file if missing
echo "[3/5] Checking configuration..."
if [ ! -f "$CONFIG_DIR/config.json" ]; then
echo "[4/5] Creating config file..."
TOKEN=$(python3 -c "import secrets; print(secrets.token_hex(16))")
cat > "$CONFIG_DIR/config.json" << EOF
{
"gateway_url": "ws://192.168.42.115:8765",
"gateway_url": "ws://YOUR-GATEWAY-HOST:8765",
"node_name": "$(hostname)",
"token": "CHANGE-ME-$(openssl rand -hex 16)",
"token": "$TOKEN",
"sexec_path": "$HOME/.openclaw/skills/sexec/sexec.sh",
"reconnect_interval": 5,
"heartbeat_interval": 30
}
EOF
echo " ⚠️ Config created at /etc/hermes-node/config.json"
echo " ⚠️ EDIT THIS FILE: Set gateway_url, node_name, and token"
echo "✓ Config: $CONFIG_DIR/config.json"
echo ""
echo "⚠️ IMPORTANT: Edit $CONFIG_DIR/config.json"
echo " → Set gateway_url to your gateway (e.g., wss://zeiss:8765)"
echo " → Set token to match gateway's token for this node"
else
echo "[4/6] Config already exists, skipping..."
echo "[4/5] Config already exists → skipping"
echo " Config: $CONFIG_DIR/config.json"
fi
# Install SysV init service
echo "[5/6] Installing SysV init service..."
sudo cp hermes-node-agent.init.d /etc/init.d/hermes-node-agent
sudo chmod +x /etc/init.d/hermes-node-agent
sudo update-rc.d hermes-node-agent defaults 2>/dev/null || true
# Enable but don't start (user needs to configure first)
echo "[6/6] Service configured..."
# Install SysV init service (root only)
if [ "$RUN_AS_ROOT" = true ] && [ -f "$SERVICE_FILE" ]; then
echo "[5/5] Installing SysV init service..."
cp "$SERVICE_FILE" /etc/init.d/hermes-node-agent
chmod +x /etc/init.d/hermes-node-agent
update-rc.d hermes-node-agent defaults 2>/dev/null || true
echo "✓ Service: /etc/init.d/hermes-node-agent"
echo ""
echo "Service commands:"
echo " /etc/init.d/hermes-node-agent start|stop|restart|status"
else
echo "[5/5] Skipping service installation (not root)"
echo ""
fi
echo ""
echo "✅ Installation complete!"
echo "=== Installation Complete ==="
echo ""
echo "Next steps:"
echo " 1. Edit /etc/hermes-node/config.json with your gateway URL and token"
echo " 2. Ensure sexec.sh is installed at the configured path"
echo " 3. Start the agent: /etc/init.d/hermes-node-agent start"
echo " 4. Check status: /etc/init.d/hermes-node-agent status"
echo " 5. View logs: tail -f /var/log/hermes-node-agent.log"
echo " 1. Edit config: $CONFIG_DIR/config.json"
echo " 2. Start agent: $AGENT_DIR/hermes-node-agent --config $CONFIG_DIR/config.json"
echo " 3. Verify logs: tail -f /tmp/hermes-node-agent.log"
echo ""
echo "For Windows nodes, see WINDOWS_INSTALL.md"
echo "For full deployment guide, see DEPLOYMENT.md"
# Hermes Node Agent — Windows Package Build Instructions
This directory contains the Windows-specific components for building a professional installer.
## Components
```
windows/
├── agent-manager.py # System tray GUI application
├── installer.iss # Inno Setup installer script
├── build.py # Automated build script
├── nssm.exe # Service wrapper (download separately)
├── icon.ico # Application icon (optional)
├── dist/ # PyInstaller output (generated)
│ ├── hermes-node-agent.exe
│ └── hermes-node-manager.exe
├── build/ # PyInstaller temp files (generated)
└── Output/ # Inno Setup output (generated)
└── hermes-node-agent-installer.exe
```
## Prerequisites
### Required Software
1. **Python 3.9+** with pip
- Download: https://www.python.org/downloads/
- Ensure "Add Python to PATH" is checked during install
2. **PyInstaller** (for creating .exe files)
```cmd
pip install pyinstaller
```
3. **Inno Setup 6** (for creating installer)
- Download: https://jrsoftware.org/isdl.php
- Install to default location: `C:\Program Files (x86)\Inno Setup 6\`
4. **NSSM** (Non-Sucking Service Manager)
- Download: https://nssm.cc/download
- Extract `nssm.exe` (64-bit version) to this `windows/` directory
### Python Dependencies
```cmd
pip install websockets pystray pillow wxpython win10toast pyinstaller
```
## Building the Installer
### Automated Build (Recommended)
Run the build script from the project root:
```cmd
cd hermes-node-protocol
python windows\build.py
```
This will:
1. Clean previous builds
2. Install Python dependencies
3. Build `hermes-node-agent.exe` with PyInstaller
4. Build `hermes-node-manager.exe` with PyInstaller
5. Verify NSSM is present
6. Compile the installer with Inno Setup
**Output:** `windows\Output\hermes-node-agent-installer.exe`
### Manual Build
If the automated script fails, build manually:
#### Step 1: Build Agent Executable
```cmd
cd hermes-node-protocol
pyinstaller --onefile --name hermes-node-agent --console node-agent\hermes_node_agent.py
```
Output: `dist\hermes-node-agent.exe`
#### Step 2: Build Manager GUI Executable
```cmd
pyinstaller --onefile --name hermes-node-manager --windowed windows\agent-manager.py
```
Output: `dist\hermes-node-manager.exe`
#### Step 3: Move Executables
```cmd
move dist\hermes-node-agent.exe windows\dist\
move dist\hermes-node-manager.exe windows\dist\
```
#### Step 4: Compile Installer
```cmd
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe" windows\installer.iss
```
Output: `windows\Output\hermes-node-agent-installer.exe`
## Testing the Installer
### Test on Clean VM
1. Create a Windows 10/11 VM (VirtualBox, Hyper-V, VMware)
2. **Do not** install Python or any dependencies
3. Copy `hermes-node-agent-installer.exe` to the VM
4. Run the installer as Administrator
5. Verify:
- Service installs: `sc query HermesNodeAgent`
- Manager starts: Check system tray for "H" icon
- Config editor opens: Right-click tray → Configuration
- Logs viewer works: Right-click tray → Logs
### Test Service Management
```cmd
REM Start service
sc start HermesNodeAgent
REM Check status
sc query HermesNodeAgent
REM Stop service
sc stop HermesNodeAgent
```
### Test Uninstaller
1. Open **Settings** → **Apps** → **Apps & features**
2. Find **Hermes Node Agent**
3. Click **Uninstall**
4. Verify all files removed except config/logs
## Customization
### Change Application Icon
1. Create or download a `.ico` file (16x16, 32x32, 48x48, 256x256)
2. Save as `windows\icon.ico`
3. Rebuild with `python windows\build.py`
### Modify Installer Appearance
Edit `windows\installer.iss`:
- **App name/version**: `[Setup]` section
- **Install location**: `DefaultDirName`
- **Start menu group**: `DefaultGroupName`
- **License agreement**: Add `LicenseFile=LICENSE.txt` to `[Setup]`
- **Wizard images**: Add `WizardImageFile` and `WizardSmallImageFile`
### Add Custom Files
Edit `windows\installer.iss` → `[Files]` section:
```iss
Source: "path\to\file.txt"; DestDir: "{app}"; Flags: ignoreversion
```
## Troubleshooting
### PyInstaller: "Module not found"
Install missing module:
```cmd
pip install <module-name>
```
Then rebuild.
### PyInstaller: "Failed to execute script"
Run the .exe from command line to see error:
```cmd
cd windows\dist
hermes-node-agent.exe --config test.json
```
### Inno Setup: "Cannot find file"
Verify all `Source:` paths in `installer.iss` exist:
- `windows\nssm.exe`
- `windows\dist\hermes-node-agent.exe`
- `windows\dist\hermes-node-manager.exe`
- `node-agent\hermes_node_agent.py`
### Manager GUI: "wxPython not found"
```cmd
pip install wxpython
```
Note: wxPython can take 5-10 minutes to install (large package).
### Service won't start after install
Check Event Viewer:
1. Press `Win+R`, type `eventvwr.msc`, press Enter
2. Navigate to **Windows Logs** → **Application**
3. Look for errors from source "HermesNodeAgent"
Common causes:
- Config file missing or invalid JSON
- Python runtime not bundled correctly (use PyInstaller `--onefile`)
- Missing DLL dependencies (use `--hidden-import` in PyInstaller)
## Distribution
### Signing the Installer (Optional but Recommended)
Sign with a code signing certificate to avoid Windows SmartScreen warnings:
```cmd
signtool sign /f certificate.pfx /p password /t http://timestamp.digicert.com windows\Output\hermes-node-agent-installer.exe
```
### Creating a Portable Version
To create a portable (no-install) version:
1. Copy `windows\dist\hermes-node-agent.exe` to a folder
2. Copy `windows\dist\hermes-node-manager.exe` to the same folder
3. Create `config.json` in the same folder
4. Zip the folder
Users can run `hermes-node-agent.exe --config config.json` directly (no service).
## File Sizes (Approximate)
- `hermes-node-agent.exe`: ~15 MB (includes Python runtime)
- `hermes-node-manager.exe`: ~25 MB (includes wxPython)
- `hermes-node-agent-installer.exe`: ~45 MB (compressed)
## Build Environment
Tested on:
- Windows 10 21H2 (x64)
- Windows 11 22H2 (x64)
- Python 3.9.13, 3.10.11, 3.11.4
- PyInstaller 5.13.0
- Inno Setup 6.2.2
## Support
For build issues:
- Check PyInstaller docs: https://pyinstaller.org/
- Check Inno Setup docs: https://jrsoftware.org/ishelp/
- Review `windows\build.py` for detailed steps
"""Hermes Node Agent — Windows GUI Manager
System tray application for managing the Hermes Node Agent service.
Provides start/stop/restart, config editing, and log viewing.
Author: Lisa (Hermes AI)
Date: 2026-04-30
"""
import os
import sys
import json
import time
import threading
import subprocess
from pathlib import Path
from datetime import datetime
import webbrowser
import pystray
from pystray import MenuItem as item, Menu
from PIL import Image, ImageDraw
import wx # For config editor dialog
# ── Configuration ────────────────────────────────────────────────────────────
CONFIG_DIR = Path(os.environ.get('PROGRAMDATA', 'C:\\ProgramData')) / 'hermes-node'
CONFIG_FILE = CONFIG_DIR / 'config.json'
AGENT_DIR = Path('C:\\Program Files\\Hermes Node')
AGENT_SCRIPT = AGENT_DIR / 'hermes-node-agent.py'
LOG_FILE = CONFIG_DIR / 'hermes-node-agent.log'
SERVICE_NAME = 'HermesNodeAgent'
NSSM_PATH = AGENT_DIR / 'nssm.exe'
# ── Status checking ──────────────────────────────────────────────────────────
def get_service_status() -> str:
"""Return: 'running', 'stopped', or 'unknown'."""
try:
result = subprocess.run(
['sc', 'query', SERVICE_NAME],
capture_output=True, text=True, timeout=5
)
if 'RUNNING' in result.stdout:
return 'running'
elif 'STOPPED' in result.stdout:
return 'stopped'
else:
return 'unknown'
except Exception:
return 'unknown'
def start_service() -> bool:
try:
subprocess.run(['sc', 'start', SERVICE_NAME], check=True, timeout=10)
return True
except subprocess.CalledProcessError:
return False
def stop_service() -> bool:
try:
subprocess.run(['sc', 'stop', SERVICE_NAME], check=True, timeout=10)
return True
except subprocess.CalledProcessError:
return False
def restart_service() -> bool:
stop_service()
time.sleep(2)
return start_service()
def get_config() -> dict:
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE) as f:
return json.load(f)
except Exception:
return {}
return {}
def save_config(config: dict) -> bool:
try:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with open(CONFIG_FILE, 'w') as f:
json.dump(config, f, indent=2)
return True
except Exception as e:
print(f"Save config error: {e}")
return False
def tail_log(lines: int = 50) -> str:
"""Return last N lines of log file."""
if LOG_FILE.exists():
try:
with open(LOG_FILE) as f:
all_lines = f.readlines()
return ''.join(all_lines[-lines:])
except Exception:
pass
return "Log file not found."
# ── GUI: Config Editor ────────────────────────────────────────────────────────
class ConfigEditor(wx.Frame):
"""Window for editing node configuration."""
def __init__(self, parent=None):
super().__init__(parent, title="Hermes Node Configuration", size=(500, 400))
self.panel = wx.Panel(self)
self.sizer = wx.BoxSizer(wx.VERTICAL)
# Form fields
grid = wx.FlexGridSizer(6, 2, 10, 10)
# Gateway URL
grid.Add(wx.StaticText(self.panel, label="Gateway URL:"), 0, wx.ALIGN_RIGHT)
self.gateway_url = wx.TextCtrl(self.panel, value="")
grid.Add(self.gateway_url, 1, wx.EXPAND)
# Node name
grid.Add(wx.StaticText(self.panel, label="Node Name:"), 0, wx.ALIGN_RIGHT)
self.node_name = wx.TextCtrl(self.panel, value="")
grid.Add(self.node_name, 1, wx.EXPAND)
# Token
grid.Add(wx.StaticText(self.panel, label="Token:"), 0, wx.ALIGN_RIGHT)
self.token = wx.TextCtrl(self.panel, value="")
grid.Add(self.token, 1, wx.EXPAND)
# sexec path
grid.Add(wx.StaticText(self.panel, label="sexec Path:"), 0, wx.ALIGN_RIGHT)
self.sexec_path = wx.TextCtrl(self.panel, value="")
grid.Add(self.sexec_path, 1, wx.EXPAND)
# Reconnect interval
grid.Add(wx.StaticText(self.panel, label="Reconnect (s):"), 0, wx.ALIGN_RIGHT)
self.reconnect = wx.TextCtrl(self.panel, value="5")
grid.Add(self.reconnect, 1, wx.EXPAND)
# Heartbeat interval
grid.Add(wx.StaticText(self.panel, label="Heartbeat (s):"), 0, wx.ALIGN_RIGHT)
self.heartbeat = wx.TextCtrl(self.panel, value="30")
grid.Add(self.heartbeat, 1, wx.EXPAND)
grid.AddGrowableCol(1, 1)
self.sizer.Add(grid, 0, wx.ALL | wx.EXPAND, 15)
# Buttons
btn_sizer = wx.BoxSizer(wx.HORIZONTAL)
save_btn = wx.Button(self.panel, label="Save")
save_btn.Bind(wx.EVT_BUTTON, self.on_save)
cancel_btn = wx.Button(self.panel, label="Cancel")
cancel_btn.Bind(wx.EVT_BUTTON, lambda e: self.Close())
btn_sizer.Add(save_btn, 0, wx.ALL, 5)
btn_sizer.Add(cancel_btn, 0, wx.ALL, 5)
self.sizer.Add(btn_sizer, 0, wx.ALIGN_RIGHT)
self.panel.SetSizer(self.sizer)
# Load existing config
self.load_config()
def load_config(self):
cfg = get_config()
self.gateway_url.SetValue(cfg.get('gateway_url', 'wss://localhost:8765'))
self.node_name.SetValue(cfg.get('node_name', os.environ.get('COMPUTERNAME', '')))
self.token.SetValue(cfg.get('token', ''))
self.sexec_path.SetValue(cfg.get('sexec_path', str(Path.home() / '.openclaw' / 'skills' / 'sexec' / 'sexec.ps1')))
self.reconnect.SetValue(str(cfg.get('reconnect_interval', 5)))
self.heartbeat.SetValue(str(cfg.get('heartbeat_interval', 30)))
def on_save(self, event):
cfg = {
'gateway_url': self.gateway_url.GetValue(),
'node_name': self.node_name.GetValue(),
'token': self.token.GetValue(),
'sexec_path': self.sexec_path.GetValue(),
'reconnect_interval': int(self.reconnect.GetValue()),
'heartbeat_interval': int(self.heartbeat.GetValue()),
}
if not cfg['gateway_url'] or not cfg['token']:
wx.MessageBox("Gateway URL and Token are required", "Error", wx.OK | wx.ICON_ERROR)
return
if save_config(cfg):
wx.MessageBox("Configuration saved.\nRestart the agent to apply changes.", "Success", wx.OK | wx.ICON_INFORMATION)
self.Close()
else:
wx.MessageBox("Failed to save config", "Error", wx.OK | wx.ICON_ERROR)
# ── GUI: Log Viewer ───────────────────────────────────────────────────────────
class LogViewer(wx.Frame):
"""Window for viewing agent logs."""
def __init__(self, parent=None):
super().__init__(parent, title="Hermes Node Agent — Logs", size=(700, 500))
panel = wx.Panel(self)
sizer = wx.BoxSizer(wx.VERTICAL)
self.log_text = wx.TextCtrl(panel, style=wx.TE_MULTILINE | wx.TE_READONLY | wx.HSCROLL)
sizer.Add(self.log_text, 1, wx.ALL | wx.EXPAND, 10)
btn_sizer = wx.BoxSizer(wx.HORIZONTAL)
refresh_btn = wx.Button(panel, label="Refresh")
refresh_btn.Bind(wx.EVT_BUTTON, lambda e: self.refresh())
tail_btn = wx.Button(panel, label="Tail (last 50)")
tail_btn.Bind(wx.EVT_BUTTON, lambda e: self.tail())
clear_btn = wx.Button(panel, label="Clear")
clear_btn.Bind(wx.EVT_BUTTON, lambda e: self.log_text.SetValue(""))
btn_sizer.Add(refresh_btn, 0, wx.ALL, 5)
btn_sizer.Add(tail_btn, 0, wx.ALL, 5)
btn_sizer.Add(clear_btn, 0, wx.ALL, 5)
sizer.Add(btn_sizer, 0, wx.ALIGN_RIGHT)
panel.SetSizer(sizer)
self.refresh()
def refresh(self):
if LOG_FILE.exists():
try:
with open(LOG_FILE) as f:
content = f.read()
self.log_text.SetValue(content)
except Exception as e:
self.log_text.SetValue(f"Error reading log: {e}")
else:
self.log_text.SetValue("Log file not found.")
def tail(self):
content = tail_log(50)
self.log_text.SetValue(content)
# ── GUI: About/Status ─────────────────────────────────────────────────────────
class StatusDialog(wx.Frame):
"""Window showing detailed node status."""
def __init__(self, parent=None):
super().__init__(parent, title="Node Status", size=(400, 300))
panel = wx.Panel(self)
sizer = wx.BoxSizer(wx.VERTICAL)
self.status_text = wx.TextCtrl(panel, style=wx.TE_MULTILINE | wx.TE_READONLY)
sizer.Add(self.status_text, 1, wx.ALL | wx.EXPAND, 10)
panel.SetSizer(sizer)
self.update_status()
# Auto-refresh timer
self.timer = wx.Timer(self)
self.Bind(wx.EVT_TIMER, lambda e: self.update_status(), self.timer)
self.timer.Start(5000) # 5 second update
def update_status(self):
status = get_service_status()
cfg = get_config()
lines = [
"=== Hermes Node Agent ===\n",
f"Service Status : {status.upper()}",
f"Node Name : {cfg.get('node_name', 'Not set')}",
f"Gateway : {cfg.get('gateway_url', 'Not set')}",
f"Token : {cfg.get('token', 'Not set')[:16]}...",
f"sexec Path : {cfg.get('sexec_path', 'Not set')}",
f"Config File : {str(CONFIG_FILE)}",
f"Log File : {str(LOG_FILE)}",
f"Agent Dir : {str(AGENT_DIR)}",
"",
"Usage: Right-click tray icon for menu",
]
self.status_text.SetValue('\n'.join(lines))
# ── Tray Icon Creation ─────────────────────────────────────────────────────────
def create_image() -> Image.Image:
"""Generate a 16x16 icon for the system tray."""
# Simple "H" icon on dark blue
img = Image.new('RGB', (16, 16), color='#003366')
draw = ImageDraw.Draw(img)
# Draw "H"
draw.rectangle([3, 3, 5, 13], fill='white')
draw.rectangle([3, 7, 12, 9], fill='white')
draw.rectangle([10, 3, 12, 13], fill='white')
return img
# ── Tray Menu Actions ──────────────────────────────────────────────────────────
def on_config_click(icon, item):
"""Open configuration editor window."""
app = wx.App(False)
frame = ConfigEditor()
frame.Show()
app.MainLoop()
def on_logs_click(icon, item):
"""Open log viewer window."""
app = wx.App(False)
frame = LogViewer()
frame.Show()
app.MainLoop()
def on_status_click(icon, item):
"""Open status window."""
app = wx.App(False)
frame = StatusDialog()
frame.Show()
app.MainLoop()
def on_start_click(icon, item):
"""Start the agent service."""
if start_service():
show_notification("Hermes Node Agent", "Service started")
else:
show_notification("Hermes Node Agent", "Failed to start service", "error")
def on_stop_click(icon, item):
"""Stop the agent service."""
if stop_service():
show_notification("Hermes Node Agent", "Service stopped")
else:
show_notification("Hermes Node Agent", "Failed to stop service", "error")
def on_restart_click(icon, item):
"""Restart the agent service."""
if restart_service():
show_notification("Hermes Node Agent", "Service restarted")
else:
show_notification("Hermes Node Agent", "Failed to restart service", "error")
def on_open_gateway_click(icon, item):
"""Open gateway web UI in browser (if available)."""
cfg = get_config()
gw_url = cfg.get('gateway_url', '')
if gw_url:
# Convert ws/wss URL to http
http_url = gw_url.replace('ws://', 'http://').replace('wss://', 'https://')
http_url = http_url.rstrip('/') + '/'
webbrowser.open(http_url)
else:
show_notification("Hermes Node Agent", "Gateway URL not configured", "warning")
def on_exit_click(icon, item):
"""Exit the tray application."""
icon.stop()
# ── Notification helper ─────────────────────────────────────────────────────────
def show_notification(title: str, message: str, level: str = 'info'):
"""Show Windows toast notification."""
try:
from win10toast import ToastNotifier
toaster = ToastNotifier()
duration = 5 if level == 'error' else 3
toaster.show_toast(title, message, duration=duration, threaded=True)
except ImportError:
# Fallback: just print
print(f"[{level.upper()}] {title}: {message}")
# ── Tray Icon ───────────────────────────────────────────────────────────────────
def build_tray_icon() -> pystray.Icon:
"""Construct and return the system tray icon."""
icon = pystray.Icon(
'HermesNodeAgent',
create_image(),
'Hermes Node Agent',
menu=Menu(
item('Configuration', on_config_click),
item('Logs', on_logs_click),
item('Status', on_status_click),
Menu.SEPARATOR,
item('Start Agent', on_start_click),
item('Stop Agent', on_stop_click),
item('Restart Agent', on_restart_click),
Menu.SEPARATOR,
item('Open Gateway UI', on_open_gateway_click),
Menu.SEPARATOR,
item('Exit', on_exit_click)
)
)
return icon
# ── Main ────────────────────────────────────────────────────────────────────────
def main():
"""Entry point for the tray manager."""
# Verify required files exist
if not CONFIG_FILE.exists():
print(f"Config not found at {CONFIG_FILE}")
print("Run the installer first, or edit/create the config file.")
sys.exit(1)
# Check if agent script exists
if not AGENT_SCRIPT.exists():
print(f"Agent script not found at {AGENT_SCRIPT}")
sys.exit(1)
# Check service is installed
result = subprocess.run(['sc', 'query', SERVICE_NAME], capture_output=True, text=True)
if result.returncode != 0:
print(f"Service '{SERVICE_NAME}' not found.")
print("Run the installer to register the service first.")
sys.exit(1)
print("Starting Hermes Node Agent Manager...")
print(f"Config: {CONFIG_FILE}")
print(f"Log: {LOG_FILE}")
icon = build_tray_icon()
icon.run()
if __name__ == '__main__':
main()
# Hermes Node Agent — Windows Build Script
# Builds the Windows installer package using PyInstaller and Inno Setup
#
# Prerequisites:
# - Python 3.9+ with pip
# - PyInstaller: pip install pyinstaller
# - Inno Setup 6: https://jrsoftware.org/isdl.php
# - NSSM: https://nssm.cc/download (place nssm.exe in windows/ folder)
#
# Usage:
# cd hermes-node-protocol
# python windows/build.py
import os
import sys
import shutil
import subprocess
from pathlib import Path
# ── Configuration ────────────────────────────────────────────────────────────
PROJECT_ROOT = Path(__file__).parent.parent
WINDOWS_DIR = PROJECT_ROOT / 'windows'
NODE_AGENT_DIR = PROJECT_ROOT / 'node-agent'
DIST_DIR = WINDOWS_DIR / 'dist'
BUILD_DIR = WINDOWS_DIR / 'build'
AGENT_SCRIPT = NODE_AGENT_DIR / 'hermes_node_agent.py'
MANAGER_SCRIPT = WINDOWS_DIR / 'agent-manager.py'
INSTALLER_SCRIPT = WINDOWS_DIR / 'installer.iss'
PYINSTALLER_AGENT_SPEC = WINDOWS_DIR / 'agent.spec'
PYINSTALLER_MANAGER_SPEC = WINDOWS_DIR / 'manager.spec'
INNO_SETUP_COMPILER = r'C:\Program Files (x86)\Inno Setup 6\ISCC.exe'
# ── Step 1: Clean previous builds ───────────────────────────────────────────
def clean():
print("[1/5] Cleaning previous builds...")
for d in [DIST_DIR, BUILD_DIR]:
if d.exists():
shutil.rmtree(d)
print(f" Removed: {d}")
DIST_DIR.mkdir(parents=True, exist_ok=True)
BUILD_DIR.mkdir(parents=True, exist_ok=True)
print(" ✓ Clean complete")
# ── Step 2: Install Python dependencies ─────────────────────────────────────
def install_deps():
print("[2/5] Installing Python dependencies...")
deps = ['websockets', 'pyinstaller', 'pystray', 'pillow', 'wxpython', 'win10toast']
for dep in deps:
print(f" Installing {dep}...")
subprocess.run([sys.executable, '-m', 'pip', 'install', '--quiet', dep], check=True)
print(" ✓ Dependencies installed")
# ── Step 3: Build executables with PyInstaller ──────────────────────────────
def build_executables():
print("[3/5] Building executables with PyInstaller...")
# Build agent executable
print(" Building hermes-node-agent.exe...")
subprocess.run([
'pyinstaller',
'--onefile',
'--name', 'hermes-node-agent',
'--distpath', str(DIST_DIR),
'--workpath', str(BUILD_DIR / 'agent'),
'--specpath', str(WINDOWS_DIR),
'--console',
'--icon', str(WINDOWS_DIR / 'icon.ico') if (WINDOWS_DIR / 'icon.ico').exists() else 'NONE',
str(AGENT_SCRIPT)
], check=True, cwd=str(PROJECT_ROOT))
# Build manager GUI executable
print(" Building hermes-node-manager.exe...")
subprocess.run([
'pyinstaller',
'--onefile',
'--name', 'hermes-node-manager',
'--distpath', str(DIST_DIR),
'--workpath', str(BUILD_DIR / 'manager'),
'--specpath', str(WINDOWS_DIR),
'--windowed', # No console window
'--icon', str(WINDOWS_DIR / 'icon.ico') if (WINDOWS_DIR / 'icon.ico').exists() else 'NONE',
str(MANAGER_SCRIPT)
], check=True, cwd=str(PROJECT_ROOT))
print(" ✓ Executables built")
# ── Step 4: Download NSSM if missing ────────────────────────────────────────
def check_nssm():
print("[4/5] Checking for NSSM...")
nssm_path = WINDOWS_DIR / 'nssm.exe'
if not nssm_path.exists():
print(" ⚠️ NSSM not found. Download from https://nssm.cc/download")
print(f" Place nssm.exe in: {WINDOWS_DIR}")
print(" (64-bit version recommended)")
return False
print(f" ✓ NSSM found: {nssm_path}")
return True
# ── Step 5: Build installer with Inno Setup ─────────────────────────────────
def build_installer():
print("[5/5] Building installer with Inno Setup...")
if not Path(INNO_SETUP_COMPILER).exists():
print(f" ⚠️ Inno Setup not found at: {INNO_SETUP_COMPILER}")
print(" Download from: https://jrsoftware.org/isdl.php")
print(" Or adjust INNO_SETUP_COMPILER path in this script")
return False
# Run Inno Setup compiler
subprocess.run([
INNO_SETUP_COMPILER,
str(INSTALLER_SCRIPT)
], check=True, cwd=str(WINDOWS_DIR))
print(" ✓ Installer built")
# Find output
output_dir = WINDOWS_DIR / 'Output'
if output_dir.exists():
installers = list(output_dir.glob('*.exe'))
if installers:
print(f"\n✅ Installer ready: {installers[0]}")
return True
print(" ⚠️ Installer output not found")
return False
# ── Main ─────────────────────────────────────────────────────────────────────
def main():
print("=== Hermes Node Agent — Windows Build Script ===\n")
# Verify we're on Windows
if sys.platform not in ('win32', 'cygwin'):
print("❌ This script must be run on Windows")
sys.exit(1)
# Verify project structure
if not AGENT_SCRIPT.exists():
print(f"❌ Agent script not found: {AGENT_SCRIPT}")
sys.exit(1)
if not MANAGER_SCRIPT.exists():
print(f"❌ Manager script not found: {MANAGER_SCRIPT}")
sys.exit(1)
try:
clean()
install_deps()
build_executables()
if not check_nssm():
print("\n⚠️ Build incomplete: NSSM missing")
print(" Download NSSM and re-run this script")
sys.exit(1)
if build_installer():
print("\n✅ Build complete!")
print("\nNext steps:")
print(" 1. Test the installer on a clean Windows VM")
print(" 2. Distribute: windows/Output/hermes-node-agent-installer.exe")
else:
print("\n⚠️ Installer build failed")
print(" Executables are available in: windows/dist/")
sys.exit(1)
except subprocess.CalledProcessError as e:
print(f"\n❌ Build failed: {e}")
sys.exit(1)
except KeyboardInterrupt:
print("\n\n⚠️ Build cancelled by user")
sys.exit(1)
if __name__ == '__main__':
main()
"""Hermes Node Agent Windows Installer — GUI (Inno Setup)
Creates a professional Windows installer (.exe) that bundles:
• Python runtime (embedded)
• Hermes node agent script
• Hermes node manager GUI
• NSSM service wrapper
• All Python dependencies (websockets)
The installer creates:
• Service: HermesNodeAgent (runs as LocalSystem)
• System tray manager (starts at user login)
• Configuration in %PROGRAMDATA%\hermes-node\
• Uninstaller in Windows "Add/Remove Programs"
Author: Lisa (Hermes AI)
Date: 2026-04-30
"""
[Setup]
AppName=Hermes Node Agent
AppVersion=2.0
AppCopyright=Copyright (c) 2026 Lisa (Hermes AI)
DefaultDirName={autopf}\Hermes Node
DefaultGroupName=Hermes Node
UninstallDisplayIcon={app}\hermes-node-manager.exe
OutputBaseFilename=hermes-node-agent-installer
Compression=lzma
SolidCompression=yes
WizardStyle=modern
PrivilegesRequired=administrator
ArchitecturesInstallIn64BitMode=x64
DisableProgramGroupPage=no
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
Name: "italian"; MessagesFile: "compiler:Italian.isl"
[Tasks]
Name: "starttray"; Description: "Start Hermes Node Manager at login (recommended)"; GroupDescription: "Additional icons:"; Flags: unchecked
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "Additional icons:"; Flags: unchecked
[Files]
; ── NSSM (service manager)
Source: "windows\nssm.exe"; DestDir: "{app}"; Flags: ignoreversion
; ── Hermes agent script
Source: "node-agent\hermes_node_agent.py"; DestDir: "{app}"; Flags: ignoreversion
; ── Agent manager GUI
Source: "windows\agent-manager.py"; DestDir: "{app}"; Flags: ignoreversion
; ── Python embedded runtime (zipapp)
Source: "windows\python-embed.zip"; DestDir: "{app}"; Flags: ignoreversion
; ── PyInstaller-converted executables (these will be built first)
Source: "windows\dist\hermes-node-agent.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "windows\dist\hermes-node-manager.exe"; DestDir: "{app}"; Flags: ignoreversion
; ── Permissions script template
Source: "windows\sexec-template.ps1"; DestName: "sexec-template.ps1"; DestDir: "{app}"; Flags: ignoreversion
; ── Documentation
Source: "WINDOWS_INSTALL.md"; DestName: "README.md"; DestDir: "{app}"; Flags: isreadme
[Icons]
Name: "{group}\Hermes Node Manager"; Filename: "{app}\hermes-node-manager.exe"
Name: "{group}\Uninstall Hermes Node Agent"; Filename: "{uninstallexe}"
Name: "{commondesktop}\Hermes Node Agent"; Filename: "{app}\hermes-node-manager.exe"; Tasks: desktopicon
[Run]
; Start the manager on first install (if user chose the task)
Filename: "{app}\hermes-node-manager.exe"; Description: "Start Hermes Node Manager"; Flags: postinstall nowait skipifsilent; Tasks: starttray
[Code]
function InitializeSetup(): Boolean;
begin
// Show welcome page
Result := True;
end;
procedure CurStepChanged(CurStep: TSetupStep);
begin
if CurStep = ssPostInstall then begin
// Create config directory
ForceDirectories(ExpandConstant('{commonappdata}\hermes-node'));
end;
end;
function NextButtonClick(CurPageID: Integer): Boolean;
begin
Result := True;
if CurPageID = wpSelectDir then begin
// Validate that Program Files is writable (needs admin)
if not IsAdminLoggedOn then begin
MsgBox('This installer requires Administrator privileges to install the service.', mbError, MB_OK);
Result := False;
end;
end;
end;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment