Commit 587bf4ce authored by Lisa's avatar Lisa

refactor: remove package-hermes-node-agent and update windows config UI

parent a3a00221
<!-- Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net> -->
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!-- Copyleft: GNU GPLv3 or later applies to this file. -->
# Hermes Node Protocol
# Copyright (c) 2026 Stefy (nextime) Lanza <stefy@nexlab.net>
# All rights reserved.
#
# This software is released under the MIT License with a copyleft clause.
# See the LICENSE file for full terms.
# Browser Control Protocol Schema
## Message Format
All browser control messages follow this structure:
```json
{
"type": "browser_control",
"id": "unique-command-id",
"layer": "high_level|playwright|cdp",
"command": "command_name",
"page_id": "page_1",
"params": {
// Command-specific parameters
}
}
```
## Response Format
```json
{
"type": "browser_control_response",
"id": "unique-command-id",
"result": "ok|error",
// Result-specific fields
}
```
---
## Layer 1: High-Level Commands
### Launch Browser
**Command:** `launch`
```json
{
"type": "browser_control",
"id": "cmd_1",
"layer": "high_level",
"command": "launch",
"params": {
"config": {
"mode": "headless", // "headless", "headed", "attach"
"cdp_url": null, // For attach mode: "http://localhost:9222"
"viewport": {"width": 1920, "height": 1080},
"timeout": 30000
}
}
}
```
**Response:**
```json
{
"type": "browser_control_response",
"id": "cmd_1",
"result": "ok",
"mode": "headless",
"browser_version": "120.0.6099.109"
}
```
### Create Context
**Command:** `create_context`
```json
{
"type": "browser_control",
"id": "cmd_2",
"layer": "high_level",
"command": "create_context",
"params": {
"config": {
"name": "my_context",
"persistent": false,
"incognito": true,
"user_data_dir": null, // For persistent: "/path/to/profile"
"viewport": {"width": 1920, "height": 1080},
"user_agent": "Custom User Agent",
"locale": "en-US",
"timezone": "America/New_York",
"geolocation": {"latitude": 40.7128, "longitude": -74.0060},
"permissions": ["geolocation", "notifications"],
"extra_http_headers": {"X-Custom": "value"},
"ignore_https_errors": false,
"java_script_enabled": true,
"bypass_csp": false
}
}
}
```
**Response:**
```json
{
"type": "browser_control_response",
"id": "cmd_2",
"result": "ok",
"context_name": "my_context",
"persistent": false
}
```
### New Page
**Command:** `new_page`
```json
{
"type": "browser_control",
"id": "cmd_3",
"layer": "high_level",
"command": "new_page",
"params": {
"context_name": "my_context" // Optional, uses "default" if not specified
}
}
```
**Response:**
```json
{
"type": "browser_control_response",
"id": "cmd_3",
"result": "ok",
"page_id": "page_1",
"context_name": "my_context"
}
```
### Navigate
**Command:** `navigate`
```json
{
"type": "browser_control",
"id": "cmd_4",
"layer": "high_level",
"command": "navigate",
"page_id": "page_1",
"params": {
"url": "https://example.com",
"wait_until": "load" // "load", "domcontentloaded", "networkidle"
}
}
```
**Response:**
```json
{
"type": "browser_control_response",
"id": "cmd_4",
"result": "ok",
"url": "https://example.com",
"status": 200,
"title": "Example Domain"
}
```
### Click
**Command:** `click`
```json
{
"type": "browser_control",
"id": "cmd_5",
"layer": "high_level",
"command": "click",
"page_id": "page_1",
"params": {
"selector": "#submit-button",
"button": "left", // Optional: "left", "right", "middle"
"click_count": 1, // Optional: number of clicks
"delay": 0 // Optional: delay between mousedown and mouseup in ms
}
}
```
**Response:**
```json
{
"type": "browser_control_response",
"id": "cmd_5",
"result": "ok"
}
```
### Fill Input
**Command:** `fill`
```json
{
"type": "browser_control",
"id": "cmd_6",
"layer": "high_level",
"command": "fill",
"page_id": "page_1",
"params": {
"selector": "#username",
"value": "myusername"
}
}
```
**Response:**
```json
{
"type": "browser_control_response",
"id": "cmd_6",
"result": "ok"
}
```
### Type Text
**Command:** `type_text`
```json
{
"type": "browser_control",
"id": "cmd_7",
"layer": "high_level",
"command": "type_text",
"page_id": "page_1",
"params": {
"selector": "#search",
"text": "search query",
"delay": 100 // Optional: delay between keystrokes in ms
}
}
```
### Wait for Selector
**Command:** `wait_for_selector`
```json
{
"type": "browser_control",
"id": "cmd_8",
"layer": "high_level",
"command": "wait_for_selector",
"page_id": "page_1",
"params": {
"selector": ".result",
"state": "visible", // "attached", "detached", "visible", "hidden"
"timeout": 30000
}
}
```
### Screenshot
**Command:** `screenshot`
```json
{
"type": "browser_control",
"id": "cmd_9",
"layer": "high_level",
"command": "screenshot",
"page_id": "page_1",
"params": {
"full_page": false,
"path": "/tmp/screenshot.png" // Optional: save to file
}
}
```
**Response:**
```json
{
"type": "browser_control_response",
"id": "cmd_9",
"result": "ok",
"screenshot": "iVBORw0KGgoAAAANSUhEUgAA...", // Base64 encoded PNG
"path": "/tmp/screenshot.png"
}
```
### Execute Script
**Command:** `execute_script`
```json
{
"type": "browser_control",
"id": "cmd_10",
"layer": "high_level",
"command": "execute_script",
"page_id": "page_1",
"params": {
"script": "document.title"
}
}
```
**Response:**
```json
{
"type": "browser_control_response",
"id": "cmd_10",
"result": "ok",
"result": "Example Domain"
}
```
### Evaluate Expression
**Command:** `evaluate`
```json
{
"type": "browser_control",
"id": "cmd_11",
"layer": "high_level",
"command": "evaluate",
"page_id": "page_1",
"params": {
"expression": "() => document.querySelectorAll('a').length",
"arg": null // Optional: argument to pass to function
}
}
```
### Get Content
**Command:** `get_content`
```json
{
"type": "browser_control",
"id": "cmd_12",
"layer": "high_level",
"command": "get_content",
"page_id": "page_1",
"params": {}
}
```
**Response:**
```json
{
"type": "browser_control_response",
"id": "cmd_12",
"result": "ok",
"content": "<!DOCTYPE html><html>..."
}
```
### Get Title
**Command:** `get_title`
```json
{
"type": "browser_control",
"id": "cmd_13",
"layer": "high_level",
"command": "get_title",
"page_id": "page_1",
"params": {}
}
```
**Response:**
```json
{
"type": "browser_control_response",
"id": "cmd_13",
"result": "ok",
"title": "Example Domain"
}
```
### List Pages
**Command:** `list_pages`
```json
{
"type": "browser_control",
"id": "cmd_14",
"layer": "high_level",
"command": "list_pages",
"params": {}
}
```
**Response:**
```json
{
"type": "browser_control_response",
"id": "cmd_14",
"result": "ok",
"pages": [
{
"page_id": "page_1",
"url": "https://example.com",
"title": "Example Domain"
}
]
}
```
### List Contexts
**Command:** `list_contexts`
```json
{
"type": "browser_control",
"id": "cmd_15",
"layer": "high_level",
"command": "list_contexts",
"params": {}
}
```
**Response:**
```json
{
"type": "browser_control_response",
"id": "cmd_15",
"result": "ok",
"contexts": [
{
"name": "default",
"page_count": 2
}
]
}
```
### Close Page
**Command:** `close_page`
```json
{
"type": "browser_control",
"id": "cmd_16",
"layer": "high_level",
"command": "close_page",
"page_id": "page_1",
"params": {}
}
```
### Close Context
**Command:** `close_context`
```json
{
"type": "browser_control",
"id": "cmd_17",
"layer": "high_level",
"command": "close_context",
"params": {
"context_name": "my_context"
}
}
```
### Close Browser
**Command:** `close`
```json
{
"type": "browser_control",
"id": "cmd_18",
"layer": "high_level",
"command": "close",
"params": {}
}
```
---
## Layer 2: Playwright API Commands
Direct access to Playwright Page API methods.
**Command:** `playwright`
```json
{
"type": "browser_control",
"id": "cmd_19",
"layer": "playwright",
"command": "locator",
"page_id": "page_1",
"params": {
"args": [".my-class"],
"kwargs": {}
}
}
```
**Examples:**
- `locator(selector)` - Get locator
- `get_by_text(text)` - Get element by text
- `get_by_role(role)` - Get element by ARIA role
- `get_by_test_id(test_id)` - Get element by test ID
- `query_selector(selector)` - Query selector
- `query_selector_all(selector)` - Query all selectors
---
## Layer 3: CDP (Chrome DevTools Protocol) Commands
Direct access to Chrome DevTools Protocol.
**Command:** `cdp`
```json
{
"type": "browser_control",
"id": "cmd_20",
"layer": "cdp",
"command": "Network.enable",
"page_id": "page_1",
"params": {}
}
```
**Common CDP Commands:**
- `Network.enable` - Enable network tracking
- `Network.getResponseBody` - Get response body
- `Performance.getMetrics` - Get performance metrics
- `Runtime.evaluate` - Evaluate JavaScript
- `Page.captureScreenshot` - Capture screenshot
- `DOM.getDocument` - Get DOM document
**Example with parameters:**
```json
{
"type": "browser_control",
"id": "cmd_21",
"layer": "cdp",
"command": "Runtime.evaluate",
"page_id": "page_1",
"params": {
"expression": "window.location.href",
"returnByValue": true
}
}
```
---
## Error Responses
```json
{
"type": "browser_control_response",
"id": "cmd_x",
"result": "error",
"error": "Error message describing what went wrong"
}
```
---
## Launch Modes
### 1. Headless Mode (default)
Browser runs without UI, ideal for automation and servers.
```json
{"mode": "headless"}
```
### 2. Headed Mode
Browser runs with visible UI, useful for debugging.
```json
{"mode": "headed"}
```
### 3. Attach Mode
Attach to existing browser instance via CDP.
```json
{
"mode": "attach",
"cdp_url": "http://localhost:9222"
}
```
To launch Chrome with remote debugging:
```bash
google-chrome --remote-debugging-port=9222
```
---
## Context Types
### 1. Incognito Context (default)
Isolated context with no persistent state.
```json
{
"name": "incognito_ctx",
"incognito": true,
"persistent": false
}
```
### 2. Persistent Context
Context with saved cookies, localStorage, etc.
```json
{
"name": "persistent_ctx",
"persistent": true,
"user_data_dir": "/path/to/profile"
}
```
### 3. Named Context
Multiple isolated contexts in same browser.
```json
{
"name": "user1_context",
"incognito": true
}
```
<!-- Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net> -->
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!-- Copyleft: GNU GPLv3 or later applies to this file. -->
# Hermes Node Protocol
# Copyright (c) 2026 Stefy (nextime) Lanza <stefy@nexlab.net>
# All rights reserved.
#
# This software is released under the MIT License with a copyleft clause.
# See the LICENSE file for full terms.
# Hermes Node Protocol - Deployment Guide
**Version:** 1.0
**Date:** 2026-04-29
Complete deployment guide for setting up the Hermes Node Protocol — a reverse-connection node execution system that replaces OpenClaw sexec with a WebSocket-based architecture.
---
## Overview
The Hermes Node Protocol enables remote command execution without SSH keys. Nodes connect to a central gateway via WebSocket, and commands are routed through this persistent connection. The existing `sexec.sh` permission system is preserved.
**Architecture:**
```
Remote Nodes (sissy, zeiss, spank, ganeti1, ganeti2)
↓ WebSocket (node-initiated)
Gateway (Hermes host)
↓ HTTP API
Hermes Agent (you)
```
**Key Benefits:**
- ✅ No SSH keys stored on gateway
- ✅ Firewall-friendly (nodes connect out)
- ✅ Reuses existing sexec permission system
- ✅ Token-based authentication
- ✅ Real-time command streaming
---
## Prerequisites
### Gateway Host (Hermes)
- Python 3.7+
- pip3
- Root access (for SysV init service)
- Ports 8765 (WebSocket) and 8766 (HTTP) available
### Remote Nodes
- Python 3.7+
- pip3
- Existing `sexec.sh` installation with `config.json`
- Network access to gateway host
---
## Part 1: Gateway Installation
### Step 1: Install Gateway
On the Hermes host:
```bash
cd ~/hermes-node-protocol/gateway
sudo ./install.sh
```
This will:
1. Install Python dependencies (websockets, aiohttp)
2. Create `hermes` system user
3. Create config directory `/etc/hermes-node-gateway/`
4. Generate random tokens for each node
5. Install SysV init service
6. Create log file `/var/log/hermes-node-gateway.log`
### Step 2: Review Configuration
```bash
sudo cat /etc/hermes-node-gateway/config.json
```
**Example config:**
```json
{
"websocket_port": 8765,
"http_port": 8766,
"bind_address": "0.0.0.0",
"tokens": {
"sissy": "a1b2c3d4e5f6...",
"zeiss": "f6e5d4c3b2a1...",
"spank": "1a2b3c4d5e6f...",
"ganeti1": "6f5e4d3c2b1a...",
"ganeti2": "abcdef123456..."
}
}
```
**Important:** Save these tokens — you'll need them for each node's configuration.
### Step 3: Start Gateway
```bash
/etc/init.d/hermes-node-gateway start
/etc/init.d/hermes-node-gateway status
```
**Check logs:**
```bash
tail -f /var/log/hermes-node-gateway.log
```
You should see:
```
Starting Hermes Node Gateway...
HTTP API listening on 0.0.0.0:8766
WebSocket server listening on 0.0.0.0:8765
Gateway is running
```
### Step 4: Verify Gateway
```bash
# Check HTTP API
curl http://localhost:8766/nodes
# Should return:
{"nodes": []}
```
Gateway is now ready to accept node connections.
---
## Part 2: Node Agent Installation
Repeat these steps for **each remote node** (sissy, zeiss, spank, ganeti1, ganeti2).
### Step 1: Copy Installer to Node
From the gateway host:
```bash
# For sissy
scp -r ~/hermes-node-protocol/node-agent openclaw@192.168.42.115:/tmp/
# For zeiss
scp -r ~/hermes-node-protocol/node-agent nextime@192.168.42.3:/tmp/
# For spank
scp -r ~/hermes-node-protocol/node-agent openclaw@192.168.231.26:/tmp/
# For ganeti1
scp -r ~/hermes-node-protocol/node-agent root@192.168.250.1:/tmp/
# For ganeti2
scp -r ~/hermes-node-protocol/node-agent root@192.168.11.1:/tmp/
```
### Step 2: Install Agent on Node
SSH to the node:
```bash
ssh openclaw@192.168.42.115 # or appropriate user@ip
```
Run installer:
```bash
cd /tmp/node-agent
./install.sh
```
This will:
1. Install Python dependencies (websockets)
2. Create config directory `/etc/hermes-node/`
3. Install agent script to `/usr/local/bin/hermes-node-agent`
4. Create example config with random token
5. Install SysV init service
6. Update init runlevel links
### Step 3: Configure Node
Edit the config file:
```bash
nano /etc/hermes-node/config.json
```
**Update these fields:**
```json
{
"gateway_url": "ws://192.168.42.115:8765",
"node_name": "sissy",
"token": "PASTE-TOKEN-FROM-GATEWAY-CONFIG",
"sexec_path": "/home/openclaw/.openclaw/skills/sexec/sexec.sh",
"reconnect_interval": 5,
"heartbeat_interval": 30
}
```
**Important:**
- `gateway_url`: WebSocket URL of your gateway (use gateway's IP)
- `node_name`: Must match the name in gateway's tokens config
- `token`: Copy from gateway's `/etc/hermes-node-gateway/config.json`
- `sexec_path`: Path to your existing sexec.sh installation
### Step 4: Verify sexec Installation
Make sure sexec is installed and working:
```bash
/home/openclaw/.openclaw/skills/sexec/sexec.sh run --command "hostname"
```
Should return the hostname without errors.
### Step 5: Start Node Agent
```bash
/etc/init.d/hermes-node-agent start
/etc/init.d/hermes-node-agent status
```
**Check logs:**
```bash
tail -f /var/log/hermes-node-agent.log
```
You should see:
```
Connecting to gateway: ws://192.168.42.115:8765
Connected to gateway
Sent registration for node 'sissy'
Registration acknowledged by gateway (version 1.0)
```
### Step 6: Verify Connection
Back on the gateway host, check connected nodes:
```bash
curl http://localhost:8766/nodes | jq
```
Should show:
```json
{
"nodes": [
{
"name": "sissy",
"status": "connected",
"last_seen": 1714392000,
"uptime": 123,
"version": "1.0",
"capabilities": ["exec", "sysinfo"]
}
]
}
```
---
## Part 3: Testing
### Test 1: Simple Command
```bash
curl -X POST http://localhost:8766/nodes/sissy/exec \
-H "Content-Type: application/json" \
-d '{"command": ["hostname"], "timeout": 10}' | jq
```
**Expected output:**
```json
{
"id": "cmd-a1b2c3d4",
"status": "completed",
"stdout": "sissy\n",
"stderr": "",
"exit_code": 0,
"error": null,
"duration_ms": 234
}
```
### Test 2: Command with Output
```bash
curl -X POST http://localhost:8766/nodes/sissy/exec \
-H "Content-Type: application/json" \
-d '{"command": ["df", "-h"], "timeout": 10}' | jq -r '.stdout'
```
Should display disk usage.
### Test 3: Using Helper Script
```bash
~/hermes-node-protocol/scripts/node-exec.sh sissy hostname
~/hermes-node-protocol/scripts/node-exec.sh sissy df -h
~/hermes-node-protocol/scripts/node-exec.sh sissy uptime
```
### Test 4: All Nodes
```bash
for node in sissy zeiss spank ganeti1 ganeti2; do
echo "=== $node ==="
~/hermes-node-protocol/scripts/node-exec.sh $node hostname 2>/dev/null || echo " (not connected)"
done
```
---
## Part 4: Integration with Hermes
The `hermes-node-exec` skill is already installed at `~/.hermes/skills/hermes-node-exec/`.
### Usage from Hermes
When you ask me to run commands on nodes, I'll use the HTTP API:
**You say:**
> "Check disk usage on sissy"
**I execute:**
```bash
curl -s -X POST http://localhost:8766/nodes/sissy/exec \
-H "Content-Type: application/json" \
-d '{"command": ["df", "-h"], "timeout": 30}' | jq -r '.stdout'
```
**You say:**
> "List Ganeti instances on ganeti1"
**I execute:**
```bash
curl -s -X POST http://localhost:8766/nodes/ganeti1/exec \
-H "Content-Type: application/json" \
-d '{"command": ["sudo", "gnt-instance", "list"], "timeout": 30}' | jq -r '.stdout'
```
---
## Troubleshooting
### Node Won't Connect
**Symptom:** Node agent logs show connection errors
**Check:**
1. Gateway is running: `/etc/init.d/hermes-node-gateway status`
2. Firewall allows port 8765: `sudo ufw status`
3. Gateway URL is correct in node config
4. Token matches between node and gateway configs
**Debug:**
```bash
# On node
tail -n 50 /var/log/hermes-node-agent.log
# On gateway
sudo tail -n 50 /var/log/hermes-node-gateway.log
```
### Invalid Token
**Symptom:** Node logs show "Invalid token"
**Solution:**
1. Check gateway config: `sudo cat /etc/hermes-node-gateway/config.json`
2. Copy correct token to node config: `/etc/hermes-node/config.json`
3. Restart node agent: `/etc/init.d/hermes-node-agent restart`
### Command Denied
**Symptom:** Command returns "Denied: ..."
**Solution:**
The command is in the node's sexec deny list. Either:
1. Remove from deny list: Edit `~/.openclaw/skills/sexec/config.json` on the node
2. Use a different command
### Command Requires Approval
**Symptom:** Command returns "approval_required"
**Solution:**
The command is in the sexec "ask" list. Get user approval, then re-run with `"approved": true`:
```bash
curl -X POST http://localhost:8766/nodes/sissy/exec \
-H "Content-Type: application/json" \
-d '{"command": ["sudo", "..."], "timeout": 30, "approved": true}'
```
### Gateway Not Responding
**Symptom:** `curl: (7) Failed to connect`
**Check:**
```bash
/etc/init.d/hermes-node-gateway status
sudo netstat -tlnp | grep 8766
```
**Restart:**
```bash
/etc/init.d/hermes-node-gateway restart
```
---
## Security Considerations
### 1. Token Security
- Tokens are stored in `/etc/hermes-node-gateway/config.json` (mode 600, owned by hermes)
- Each node has a unique token
- Tokens are 64-character random hex strings
- If a token is compromised, regenerate and update both gateway and node configs
### 2. Network Security
- Gateway listens on `0.0.0.0` by default (all interfaces)
- Consider binding to specific interface: `"bind_address": "192.168.42.115"`
- Use firewall rules to restrict access:
```bash
sudo ufw allow from 192.168.42.0/24 to any port 8765
sudo ufw allow from 192.168.42.0/24 to any port 8766
```
### 3. Permission System
- Each node enforces its own sexec allow/deny/ask lists
- Gateway cannot bypass node permissions
- Commands in "ask" list require explicit user approval
- Commands in "deny" list are rejected immediately
### 4. TLS/WSS (Future Enhancement)
Current version uses unencrypted WebSocket (ws://). For production:
- Use WSS (WebSocket Secure) with TLS certificates
- Encrypt HTTP API with HTTPS
- Use certificate-based authentication instead of tokens
---
## Maintenance
### View Gateway Logs
```bash
tail -f /var/log/hermes-node-gateway.log
```
### View Node Logs
```bash
ssh user@node 'tail -f /var/log/hermes-node-agent.log'
```
### Restart Gateway
```bash
/etc/init.d/hermes-node-gateway restart
```
### Restart Node Agent
```bash
ssh user@node '/etc/init.d/hermes-node-agent restart'
```
### Update Gateway
```bash
cd ~/hermes-node-protocol/gateway
sudo cp hermes_node_gateway.py /usr/local/bin/hermes-node-gateway
/etc/init.d/hermes-node-gateway restart
```
### Update Node Agent
```bash
# On each node
cd /tmp/node-agent
sudo cp hermes_node_agent.py /usr/local/bin/hermes-node-agent
/etc/init.d/hermes-node-agent restart
```
---
## Migration from OpenClaw
If you're migrating from OpenClaw sexec:
### What to Keep
- ✅ Keep `sexec.sh` on all nodes
- ✅ Keep `config.json` permission files
- ✅ Keep existing allow/deny/ask lists
### What to Change
- ❌ Remove OpenClaw gateway (optional)
- ❌ Remove OpenClaw node agents (optional)
- ✅ Install Hermes Node Gateway
- ✅ Install Hermes Node Agents
- ✅ Update Hermes skills to use new HTTP API
### Coexistence
You can run both systems in parallel during migration:
- OpenClaw gateway on different ports
- Hermes gateway on ports 8765/8766
- Both can use the same sexec.sh installations
---
## Next Steps
1. ✅ Gateway installed and running
2. ✅ All nodes connected
3. ✅ Test commands working
4. ✅ Hermes skill installed
**You're ready to use the system!**
Try asking me:
- "Check disk usage on all nodes"
- "List Ganeti instances on ganeti1"
- "Check uptime on zeiss"
I'll use the Hermes Node Protocol to execute these commands.
---
## Files Reference
| File | Location | Purpose |
|------|----------|---------|
| Protocol Spec | `~/hermes-node-protocol/PROTOCOL.md` | Message format and architecture |
| Gateway Script | `~/hermes-node-protocol/gateway/hermes_node_gateway.py` | Gateway server code |
| Gateway Config | `/etc/hermes-node-gateway/config.json` | Gateway configuration and tokens |
| Gateway Service | `/etc/init.d/hermes-node-gateway` | SysV init script |
| Node Agent Script | `~/hermes-node-protocol/node-agent/hermes_node_agent.py` | Node agent code |
| Node Agent Config | `/etc/hermes-node/config.json` | Node configuration (on each node) |
| Node Agent Service | `/etc/init.d/hermes-node-agent` | SysV init script |
| Helper Script | `~/hermes-node-protocol/scripts/node-exec.sh` | CLI wrapper |
| Hermes Skill | `~/.hermes/skills/hermes-node-exec/SKILL.md` | Skill documentation |
---
## Support
For issues or questions:
1. Check logs (gateway and node)
2. Verify network connectivity
3. Confirm tokens match
4. Test with simple commands first
5. Review the protocol spec for message formats
**Common issues are documented in the Troubleshooting section above.**
MIT License
Copyright (c) 2026 Lisa (Hermes AI) / OpenClaw Project
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
<!-- Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net> -->
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!-- Copyleft: GNU GPLv3 or later applies to this file. -->
# Hermes Node Protocol
# Copyright (c) 2026 Stefy (nextime) Lanza <stefy@nexlab.net>
# All rights reserved.
#
# This software is released under the MIT License with a copyleft clause.
# See the LICENSE file for full terms.
# Hermes Node Protocol Specification
**Version:** 1.0
**Date:** 2026-04-29
**Purpose:** Reverse-connection node execution with permission model, compatible with existing OpenClaw `sexec.sh` scripts.
---
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────┐
│ Hermes Gateway │
│ ┌────────────────────────────────────────────────────┐ │
│ │ WebSocket Server (port 8765) │ │
│ │ - Accepts node connections │ │
│ │ - Authenticates via token │ │
│ │ - Maintains node registry │ │
│ │ - HTTP API for command submission │ │
│ └─────────────────────────────┬───────────────────────┘ │
│ │ routes │
│ ┌─────────────────────────────▼───────────────────────┐ │
│ │ Command Router + Approval Engine │ │
│ │ - Matches commands to nodes │ │
│ │ - Handles "ask" list approval via user prompts │ │
│ │ - Streams output back │ │
│ └────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
▲ │
│ WebSocket (node-initiated) │ HTTP/WS
│ │
┌────────┴─────────────────────────────────┴─────────────┐
│ Remote Node Agent │
│ ┌──────────────────────────────────────────────────┐ │
│ │ WebSocket Client → connects to gateway │ │
│ │ - Auto-reconnect on disconnect │ │
│ │ - Heartbeat every 30s │ │
│ │ - Handles auth token │ │
│ └───────────────────────────┬──────────────────────┘ │
│ │ receives │
│ ┌───────────────────────────▼──────────────────────┐ │
│ │ Command Executor (sexec wrapper) │ │
│ │ - Runs: /path/to/sexec.sh run --command ... │ │
│ │ - Streams stdout/stderr back │ │
│ │ - Returns exit code │ │
│ └──────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
Permission System (sexec.sh + config.json) - reused exactly
```
---
## Message Protocol
All messages are JSON over WebSocket.
### 1. Node → Gateway: Registration
**On connect:**
```json
{
"type": "register",
"node_name": "sissy",
"version": "1.0",
"capabilities": ["exec", "sysinfo"],
"sexec_path": "/home/openclaw/.openclaw/skills/sexec/sexec.sh"
}
```
**Gateway response:**
```json
{
"type": "register_ack",
"status": "ok",
"node_id": "sissy",
"gateway_version": "1.0"
}
```
### 2. Node → Gateway: Heartbeat
**Every 30 seconds:**
```json
{
"type": "heartbeat",
"timestamp": 1714392000
}
```
**Gateway response:**
```json
{
"type": "heartbeat_ack",
"timestamp": 1714392000
}
```
### 3. Gateway → Node: Execute Command
```json
{
"type": "exec",
"id": "cmd-a1b2c3d4",
"command": ["df", "-h"],
"timeout": 30,
"approved": false
}
```
**Fields:**
- `id`: Unique command ID for tracking
- `command`: Array of command + args (e.g., `["df", "-h"]`)
- `timeout`: Max execution time in seconds
- `approved`: If `true`, bypass "ask" list (user explicitly approved)
### 4. Node → Gateway: Command Output (streaming)
**Stdout chunk:**
```json
{
"type": "exec_output",
"id": "cmd-a1b2c3d4",
"stream": "stdout",
"data": "Filesystem Size Used Avail Use% Mounted on\n"
}
```
**Stderr chunk:**
```json
{
"type": "exec_output",
"id": "cmd-a1b2c3d4",
"stream": "stderr",
"data": "warning: deprecated option\n"
}
```
### 5. Node → Gateway: Command Complete
```json
{
"type": "exec_complete",
"id": "cmd-a1b2c3d4",
"exit_code": 0,
"duration_ms": 1234
}
```
### 6. Node → Gateway: Approval Required
**When command matches "ask" list:**
```json
{
"type": "exec_approval_required",
"id": "cmd-a1b2c3d4",
"command": ["sudo", "gnt-instance", "stop", "prod-db"],
"reason": "Command matches ask pattern: 'sudo gnt-instance stop *'"
}
```
**Gateway forwards to user, then responds:**
```json
{
"type": "exec_approval_response",
"id": "cmd-a1b2c3d4",
"approved": true,
"add_to_allowlist": false
}
```
### 7. Node → Gateway: Command Denied
**When command matches "deny" list:**
```json
{
"type": "exec_denied",
"id": "cmd-a1b2c3d4",
"command": ["rm", "-rf", "/"],
"reason": "Command matches deny pattern: 'rm -rf /*'"
}
```
### 8. Gateway → Node: Disconnect
```json
{
"type": "disconnect",
"reason": "gateway_shutdown"
}
```
---
## Browser Control
**Optional capability** — requires Playwright on the node.
### Capability Registration
## Camera Control
**Optional capability** — phase-1 Linux support uses `ffmpeg` + V4L2 (`/dev/video*`). The node only advertises `camera_control` in its `tools` list when `enable_camera_control` is true *and* a usable camera backend/device is present at runtime.
### Actions
- `list_cameras` — enumerate detected camera devices and probe metadata
- `get_camera_status` — current camera backend readiness and discovered devices
- `capture_frame` — grab a single still frame from a selected/default device
- `capture_video` — record a short video clip from a selected/default device
### Gateway → Node: Camera Control
```json
{
"type": "camera_control",
"id": "camera-a1b2c3d4",
"action": "capture_frame",
"params": {
"device": "/dev/video0",
"format": "png",
"width": 1280,
"height": 720
}
}
```
### Node → Gateway: Camera Control Result
```json
{
"type": "camera_control_result",
"id": "camera-a1b2c3d4",
"action": "capture_frame",
"success": true,
"device": "/dev/video0",
"format": "png",
"path": "/tmp/hermes-camera-1714392000.png",
"size_bytes": 123456,
"data_base64": "..."
}
```
Node registers optional tools in its `tools` list during registration and exposes structured readiness metadata under `capabilities`. Existing siblings include `browser_control`, `computer_control`, `desktop_observe`, `audio_control`, and `camera_control`.
```json
{
"type": "register",
"node_name": "sissy",
"version": "1.0",
"capabilities": ["exec", "sysinfo", "browser_control"],
"sexec_path": "/home/openclaw/.openclaw/skills/sexec/sexec.sh"
}
```
---
## Authentication
### Token-Based Auth
**Node config file** (`/etc/hermes-node/config.json`):
```json
{
"gateway_url": "ws://192.168.42.115:8765",
"node_name": "sissy",
"token": "node-sissy-secret-token-abc123",
"sexec_path": "/home/openclaw/.openclaw/skills/sexec/sexec.sh",
"reconnect_interval": 5,
"heartbeat_interval": 30,
"enable_camera_control": false
}
```
**WebSocket connection URL:**
```
ws://192.168.42.115:8765/nodes?token=node-sissy-secret-token-abc123
```
Gateway validates token against stored registry:
```json
{
"sissy": "node-sissy-secret-token-abc123",
"zeiss": "node-zeiss-secret-token-def456",
"spank": "node-spank-secret-token-ghi789"
}
```
---
## Gateway HTTP API
For Hermes skill to submit commands.
### POST /nodes/{node_name}/exec
**Request:**
```json
{
"command": ["df", "-h"],
"timeout": 30,
"approved": false
}
```
**Response (streaming):**
```json
{"type": "stdout", "data": "Filesystem Size...\n"}
{"type": "stderr", "data": ""}
{"type": "exit", "code": 0}
```
### GET /nodes
**Response:**
```json
{
"nodes": [
{
"name": "sissy",
"status": "connected",
"last_seen": 1714392000,
"uptime": 86400
},
{
"name": "zeiss",
"status": "connected",
"last_seen": 1714392005,
"uptime": 172800
}
]
}
```
### GET /nodes/{node_name}/status
**Response:**
```json
{
"name": "sissy",
"status": "connected",
"last_seen": 1714392000,
"uptime": 86400,
"version": "1.0",
"capabilities": ["exec", "sysinfo"]
}
```
---
## Security Model
### 1. No Gateway SSH Keys
- Gateway never stores SSH keys for nodes
- Gateway never initiates connections to nodes
- Nodes connect out to gateway (firewall-friendly)
### 2. Token Authentication
- Each node has unique pre-shared token
- Tokens stored in `/etc/hermes-node/config.json` on node
- Tokens stored in gateway registry (file or DB)
### 3. Permission System (Reused from sexec)
- Each node keeps existing `sexec.sh` + `config.json`
- `allow` list: auto-execute
- `ask` list: require user approval
- `deny` list: reject immediately
### 4. Command Approval Flow
```
User → Hermes → Gateway → Node
sexec checks config.json
matches "ask" list
sends approval_required
Gateway → User: "Approve 'sudo gnt-instance stop prod-db'?"
User: "yes"
Gateway → Node: approved=true
sexec executes
```
---
## Error Handling
### Node Disconnection
- Gateway marks node as "disconnected"
- Queued commands return error: `{"error": "node_offline"}`
- Node auto-reconnects with exponential backoff (5s, 10s, 20s, max 60s)
### Command Timeout
- If node doesn't respond within `timeout` seconds:
- Gateway sends `{"type": "exec_cancel", "id": "..."}`
- Node kills process
- Returns `{"type": "exec_complete", "exit_code": -1, "error": "timeout"}`
### Gateway Restart
- Nodes detect disconnect via heartbeat failure
- Nodes reconnect automatically
- In-flight commands are lost (client must retry)
---
## Deployment
### Gateway Side
1. Install `hermes-node-gateway` service
2. Configure tokens in `/etc/hermes-node-gateway/tokens.json`
3. Start service: `/etc/init.d/hermes-node-gateway start`
4. Install `hermes-node-exec` skill for Hermes
### Node Side
1. Install `hermes-node-agent` package
2. Configure `/etc/hermes-node/config.json` with gateway URL + token
3. Ensure `sexec.sh` is installed and configured
4. Start service: `/etc/init.d/hermes-node-agent start`
5. Verify connection: `tail -f /var/log/hermes-node-agent.log`
---
## Compatibility
### With OpenClaw sexec
- ✅ Reuses exact same `sexec.sh` binary
- ✅ Reuses exact same `config.json` format
- ✅ Reuses exact same permission logic
- ✅ No changes needed to existing sexec installations
### Migration from OpenClaw
1. Keep existing sexec on nodes
2. Install node agent alongside
3. Configure gateway URL + token
4. Start agent
5. Disable OpenClaw gateway (optional)
---
## Future Enhancements
- **TLS/WSS support** for encrypted connections
- **Certificate-based auth** instead of tokens
- **Command history** and audit log
- **Multi-gateway failover** (node connects to backup if primary down)
- **Bidirectional file transfer** (upload/download via WebSocket)
- **Real-time log streaming** (tail -f over WebSocket)
<!-- Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net> -->
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!-- Copyleft: GNU GPLv3 or later applies to this file. -->
# Hermes Node Agent
**Version:** 2.0
**Repository:** `git@git.nexlab.net:lisa/hermes-node-agent.git`
Cross-platform node agent for the Hermes Node Protocol. Connects to a central gateway via WebSocket and executes commands with permission enforcement.
---
## Features
- **Cross-platform**: Linux and Windows support
- **Reverse connection**: Nodes connect to gateway (firewall-friendly)
- **Token authentication**: Secure per-node tokens
- **Permission system**: sexec-based allow/deny/ask rules
- **Auto-reconnect**: Exponential backoff on disconnect
- **Heartbeat**: Keep-alive mechanism
- **Optional capabilities**: Browser control, computer control
---
## Platforms
### Linux
- Bash installer with SysV init service
- CLI-based configuration
- Runs as daemon
### Windows
- Graphical installer (.exe via Inno Setup)
- System tray GUI manager
- Windows Service integration (NSSM)
- Configuration editor (no manual JSON editing)
- Log viewer with auto-refresh
---
## Installation
### Linux
```bash
sudo ./install.sh
sudo nano /etc/hermes-node/config.json # Edit gateway_url and token
sudo /etc/init.d/hermes-node-agent start
```
### Windows
1. Build installer (on Windows dev machine):
```cmd
python windows\build.py
```
2. Run `windows\Output\hermes-node-agent-installer.exe` as Administrator
3. Configure via system tray: Right-click icon → Configuration
---
## Configuration
**Linux:** `/etc/hermes-node/config.json`
**Windows:** `C:\ProgramData\hermes-node\config.json`
```json
{
"gateway_url": "wss://gateway-host:8765",
"node_name": "my-node",
"token": "your-token-here",
"sexec_path": "/path/to/sexec.sh",
"reconnect_interval": 5,
"heartbeat_interval": 30
}
```
---
### Browser Control Capability
For browser automation support, the bot control capability uses the **Hermes Node Chrome Extension**:
**Repository:** `git@git.nexlab.net:lisa/hermes-node-chrome.git`
The extension enables DOM manipulation, screenshots, and click/type automation.
---
## Files
```
node-agent/
├── hermes_node_agent.py # Main agent (cross-platform)
├── browser_controller.py # Browser control capability
├── install.sh # Linux installer
├── install-windows.ps1 # Windows PowerShell installer (legacy)
├── hermes-node-agent.init.d # SysV init script
├── hermes-node-agent.service # systemd unit (alternative)
├── requirements.txt # Python dependencies
└── windows/ # Windows-specific components
├── agent-manager.py # System tray GUI
├── installer.iss # Inno Setup script
├── build.py # Build automation
└── README.md # Build instructions
```
---
## Documentation
- **DEPLOYMENT.md** — Full deployment guide
- **WINDOWS_DEPLOYMENT.md** — Windows-specific guide
- **PROTOCOL.md** — WebSocket protocol specification
- **windows/README.md** — Windows build instructions
---
## Related Repositories
- **Gateway Plugin:** `~/.hermes/plugins/hermes-node-gateway/` (loaded by Hermes Agent)
- **Node Agent:** `git@git.nexlab.net:lisa/hermes-node-agent.git`
- **Chrome Extension:** `git@git.nexlab.net:lisa/hermes-node-chrome.git`
---
## License
MIT License — See the [LICENSE](LICENSE) file in this repository.
---
## Support
For issues, check:
- Agent logs: `/var/log/hermes-node-agent.log` (Linux) or `C:\ProgramData\hermes-node\hermes-node-agent.log` (Windows)
- Gateway logs on gateway host
- Configuration files for token/URL mismatches
## Donate
If you find this project useful, consider supporting its continued development:
**Bitcoin (BTC)**: `bc1ql5klyv78t0a5g59y3tczv8ejy9l740x3zg0cge`
**Ethereum (ETH)**: `0x3f707d3543A6C301B3Bf47eBc4B469e017a119B4`
**Solana (SOL)**: `G7iZQ3iQ7k5t9E9g8Y7u6i5t4r3e2w1q0P9O8I7U6Y5`
---
*Copyright (c) 2026 Stefy (nextime) Lanza <stefy@nexlab.net>. All rights reserved.*
<!-- Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net> -->
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!-- Copyleft: GNU GPLv3 or later applies to this file. -->
# Hermes Node Protocol
# Copyright (c) 2026 Stefy (nextime) Lanza <stefy@nexlab.net>
# All rights reserved.
#
# This software is released under the MIT License with a copyleft clause.
# See the LICENSE file for full terms.
# Hermes Node Agent — Windows Deployment Guide
**Platform:** Windows 10/11, Windows Server 2016+
**Version:** 2.0
**Date:** 2026-04-30
This guide covers deploying the Hermes Node Agent on Windows machines using the graphical installer.
---
## Overview
The Windows package includes:
- **hermes-node-agent.exe** — Main agent (connects to gateway)
- **hermes-node-manager.exe** — System tray GUI for management
- **NSSM** — Service wrapper (runs agent as Windows service)
- **Graphical installer** — One-click setup with uninstaller
---
## Installation
### Option A: Graphical Installer (Recommended)
1. **Download** `hermes-node-agent-installer.exe`
2. **Right-click****Run as Administrator**
3. Follow the installation wizard:
- Accept license
- Choose install location (default: `C:\Program Files\Hermes Node`)
- Select components:
- ☑ Install Windows Service (recommended)
- ☑ Start Manager at login (recommended)
- ☐ Create desktop shortcut (optional)
4. Click **Install**
5. When prompted, click **Finish** to launch the manager
### Option B: Silent Installation (for deployment tools)
```cmd
hermes-node-agent-installer.exe /VERYSILENT /NORESTART /TASKS="starttray"
```
Parameters:
- `/VERYSILENT` — No UI, no prompts
- `/NORESTART` — Don't reboot after install
- `/TASKS="starttray"` — Enable auto-start at login
- `/DIR="C:\Custom\Path"` — Custom install directory
---
## First-Time Configuration
After installation, the **Hermes Node Manager** will appear in your system tray (near the clock).
### Step 1: Configure Connection
1. **Right-click** the tray icon (blue "H")
2. Select **Configuration**
3. Fill in the required fields:
- **Gateway URL**: `wss://your-gateway-host:8765`
- **Node Name**: Unique identifier (default: computer name)
- **Token**: Authentication token from gateway admin
- **sexec Path**: (optional) Path to permission script
- **Reconnect**: Seconds between reconnect attempts (default: 5)
- **Heartbeat**: Seconds between heartbeats (default: 30)
4. Click **Save**
### Step 2: Start the Agent
1. **Right-click** the tray icon
2. Select **Start Agent**
3. Wait 5-10 seconds for connection
4. Verify in **Status** window (should show "RUNNING")
---
## Using the Manager
### System Tray Menu
Right-click the tray icon to access:
| Menu Item | Description |
|-----------|-------------|
| **Configuration** | Edit gateway URL, token, and settings |
| **Logs** | View agent logs (last 50 lines or full) |
| **Status** | Show connection status and config summary |
| **Start Agent** | Start the Windows service |
| **Stop Agent** | Stop the Windows service |
| **Restart Agent** | Restart the service (apply config changes) |
| **Open Gateway UI** | Open gateway web interface in browser |
| **Exit** | Close the manager (agent keeps running) |
### Configuration Window
**Fields:**
- **Gateway URL**: WebSocket address of the gateway
- Format: `wss://hostname:8765` (secure) or `ws://hostname:8765` (insecure)
- Must match the gateway's WebSocket port
- **Node Name**: Unique identifier for this node
- Must match a token entry in gateway's `config.json`
- Default: Windows computer name
- **Token**: Authentication secret
- Provided by gateway administrator
- Keep this secure (treat like a password)
- **sexec Path**: (Optional) Path to PowerShell permission script
- Default: `%USERPROFILE%\.openclaw\skills\sexec\sexec.ps1`
- Leave blank to allow all commands (not recommended)
- **Reconnect Interval**: Seconds to wait before reconnecting after disconnect
- **Heartbeat Interval**: Seconds between keep-alive pings
**After changing config:**
- Click **Save**
- **Restart Agent** from the tray menu to apply changes
### Log Viewer
View real-time agent logs:
1. Right-click tray icon → **Logs**
2. Click **Refresh** to reload
3. Click **Tail (last 50)** to see recent entries
4. Click **Clear** to empty the view
**Log file location:** `C:\ProgramData\hermes-node\hermes-node-agent.log`
### Status Window
Shows:
- Service status (RUNNING / STOPPED)
- Current configuration summary
- File paths (config, logs, agent)
- Auto-refreshes every 5 seconds
---
## Service Management
The agent runs as a Windows service named **HermesNodeAgent**.
### Via Tray Manager (Recommended)
- **Start**: Right-click tray icon → Start Agent
- **Stop**: Right-click tray icon → Stop Agent
- **Restart**: Right-click tray icon → Restart Agent
### Via Command Line
```cmd
REM Start service
sc start HermesNodeAgent
REM Stop service
sc stop HermesNodeAgent
REM Query status
sc query HermesNodeAgent
REM View service config
sc qc HermesNodeAgent
```
### Via Services.msc
1. Press `Win+R`, type `services.msc`, press Enter
2. Find **HermesNodeAgent** in the list
3. Right-click → Start / Stop / Restart
4. Right-click → Properties to configure startup type
---
## Permissions (sexec)
The Windows agent supports the same permission system as Linux.
### Creating sexec.ps1
Create `%USERPROFILE%\.openclaw\skills\sexec\sexec.ps1`:
```powershell
# Hermes Node Agent — Permission Enforcement Script
param()
$command = $env:SEXEC_COMMAND
if (-not $command) {
Write-Error "SEXEC_COMMAND not set"
exit 1
}
# Load permissions from JSON
$permPath = "$env:USERPROFILE\.openclaw\skills\sexec\permissions.json"
if (Test-Path $permPath) {
$perms = Get-Content $permPath | ConvertFrom-Json
# Deny list (highest priority)
foreach ($pattern in $perms.deny) {
if ($command -match $pattern) {
Write-Error "Denied by pattern: $pattern"
exit 1
}
}
# Ask list (requires approval)
foreach ($pattern in $perms.ask) {
if ($command -match $pattern) {
Write-Error "Command requires approval"
exit 2
}
}
# Allow list (if present, command must match)
if ($perms.allow -and $perms.allow.Count -gt 0) {
$allowed = $false
foreach ($pattern in $perms.allow) {
if ($command -match $pattern) {
$allowed = $true
break
}
}
if (-not $allowed) {
Write-Error "Not in allow list"
exit 1
}
}
}
# Execute command
Invoke-Expression $command
exit $LASTEXITCODE
```
### Creating permissions.json
Create `%USERPROFILE%\.openclaw\skills\sexec\permissions.json`:
```json
{
"allow": [
"^dir\\b",
"^Get-",
"^Test-Path",
"^whoami"
],
"deny": [
"Remove-Item.*-Recurse",
"Format-Volume",
"Stop-Computer",
"Restart-Computer"
],
"ask": [
"Install-",
"Uninstall-",
"Set-ExecutionPolicy"
]
}
```
**Patterns are PowerShell regex:**
- `^` = start of command
- `\\b` = word boundary
- `.*` = any characters
- Case-insensitive by default
---
## Firewall Configuration
The agent makes **outbound connections only** (no inbound ports needed).
### Allow Outbound Connection
If Windows Firewall blocks the agent:
1. Open **Windows Defender Firewall with Advanced Security**
2. Click **Outbound Rules****New Rule**
3. Select **Program** → Next
4. Browse to: `C:\Program Files\Hermes Node\hermes-node-agent.exe`
5. Select **Allow the connection** → Next
6. Apply to all profiles (Domain, Private, Public) → Next
7. Name: "Hermes Node Agent" → Finish
### Test Connectivity
```powershell
# Test gateway reachability
Test-NetConnection -ComputerName your-gateway-host -Port 8765
# Should show: TcpTestSucceeded : True
```
---
## Troubleshooting
### Agent won't start
**Check service status:**
```cmd
sc query HermesNodeAgent
```
**View service errors:**
```cmd
Get-EventLog -LogName Application -Source HermesNodeAgent -Newest 10
```
**Common causes:**
- Config file missing or invalid JSON
- Python not installed (embedded version should be bundled)
- Gateway URL unreachable
### Connection refused
1. Verify gateway is running: `curl https://your-gateway:8766/health`
2. Check token matches gateway config
3. Ensure node name matches token entry in gateway
4. Check firewall allows outbound HTTPS/WSS
### Permission denied errors
1. Verify `sexec_path` points to an existing `.ps1` file
2. Check `permissions.json` syntax (valid JSON)
3. Test sexec manually:
```powershell
$env:SEXEC_COMMAND = "whoami"
& "$env:USERPROFILE\.openclaw\skills\sexec\sexec.ps1"
```
### Manager won't start
**Check if already running:**
```powershell
Get-Process hermes-node-manager -ErrorAction SilentlyContinue
```
**Kill and restart:**
```powershell
Stop-Process -Name hermes-node-manager -Force
& "C:\Program Files\Hermes Node\hermes-node-manager.exe"
```
### Logs show "Token invalid"
1. Open config: `notepad C:\ProgramData\hermes-node\config.json`
2. Verify token matches gateway's `config.json` entry for this node
3. Restart agent after fixing
---
## Uninstallation
### Via Control Panel
1. Open **Settings** → **Apps** → **Apps & features**
2. Find **Hermes Node Agent**
3. Click **Uninstall**
4. Follow the wizard
### Via Command Line
```cmd
"C:\Program Files\Hermes Node\unins000.exe" /VERYSILENT /NORESTART
```
**What gets removed:**
- Agent executable and manager
- Windows service registration
- Start menu shortcuts
- Desktop shortcut (if created)
**What stays (manual cleanup required):**
- Config: `C:\ProgramData\hermes-node\config.json`
- Logs: `C:\ProgramData\hermes-node\hermes-node-agent.log`
- sexec scripts: `%USERPROFILE%\.openclaw\skills\sexec\`
---
## Advanced: Running Without Service
For testing or development, run the agent directly:
```cmd
cd "C:\Program Files\Hermes Node"
hermes-node-agent.exe --config "C:\ProgramData\hermes-node\config.json"
```
Press `Ctrl+C` to stop.
---
## Files Installed
| Path | Description |
|------|-------------|
| `C:\Program Files\Hermes Node\hermes-node-agent.exe` | Main agent executable |
| `C:\Program Files\Hermes Node\hermes-node-manager.exe` | System tray manager GUI |
| `C:\Program Files\Hermes Node\nssm.exe` | Service wrapper |
| `C:\ProgramData\hermes-node\config.json` | Configuration file |
| `C:\ProgramData\hermes-node\hermes-node-agent.log` | Log file |
| `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Hermes Node\` | Start menu shortcuts |
---
## Support
For issues:
- Check logs: `C:\ProgramData\hermes-node\hermes-node-agent.log`
- Check gateway logs: `/var/log/hermes-node-gateway.log` (on gateway host)
- Review DEPLOYMENT.md for architecture details
- Contact gateway administrator for token/connectivity issues
#!/usr/bin/env python3
# Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyleft: this program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# Hermes Node Protocol
# Copyright (c) 2026 Stefy (nextime) Lanza <stefy@nexlab.net>
# All rights reserved.
#
# This software is released under the MIT License with a copyleft clause.
# See the LICENSE file for full terms.
"""
Browser control module for Hermes Node Protocol.
Implements the interface expected by hermes_node_agent.py.
"""
import asyncio
import base64
import logging
from typing import Dict, Any, Optional
from pathlib import Path
try:
from playwright.async_api import async_playwright, Browser, BrowserContext, Page, Error as PlaywrightError
HAS_PLAYWRIGHT = True
except ImportError:
HAS_PLAYWRIGHT = False
Browser = BrowserContext = Page = None
logger = logging.getLogger(__name__)
class BrowserController:
"""Manages browser instances and contexts for remote control."""
def __init__(self):
self.playwright = None
self.browser: Optional[Browser] = None
self.contexts: Dict[str, BrowserContext] = {} # name -> context
self.default_context: Optional[BrowserContext] = None
self.pages: Dict[str, Page] = {} # page_id -> page
self.lock = asyncio.Lock()
async def initialize(self):
"""Initialize Playwright."""
if not HAS_PLAYWRIGHT:
raise RuntimeError("Playwright not installed. Run: pip install playwright && playwright install chromium")
self.playwright = await async_playwright().start()
logger.info("Playwright initialized")
async def shutdown(self):
"""Clean shutdown of all browser resources."""
try:
# Close all contexts
for ctx in self.contexts.values():
await ctx.close()
if self.default_context:
await self.default_context.close()
# Close browser
if self.browser:
await self.browser.close()
# Stop playwright
if self.playwright:
await self.playwright.stop()
logger.info("Browser controller shutdown complete")
except Exception as e:
logger.error(f"Error during shutdown: {e}")
# Launch and context management
async def launch(self, config: Dict = None) -> Dict[str, Any]:
"""Launch the browser."""
config = config or {}
headless = config.get("headless", False)
attach = config.get("attach", False)
cdp_url = config.get("cdp_url", "http://localhost:9222")
browser_type = config.get("browser_type", "chromium")
try:
if attach:
# Connect to existing browser
endpoints = {
"chromium": f"{cdp_url}",
"firefox": f"{cdp_url.replace(':9222', ':9200') if ':9222' in cdp_url else cdp_url}",
"webkit": f"{cdp_url.replace(':9222', ':9223') if ':9222' in cdp_url else cdp_url}"
}
endpoint = endpoints.get(browser_type, cdp_url)
if browser_type == "chromium":
self.browser = await self.playwright.chromium.connect_over_cdp(endpoint)
elif browser_type == "firefox":
self.browser = await self.playwright.firefox.connect_over_cdp(endpoint)
elif browser_type == "webkit":
self.browser = await self.playwright.webkit.connect_over_cdp(endpoint)
else:
self.browser = await self.playwright.chromium.connect_over_cdp(endpoint)
logger.info(f"Attached to existing {browser_type} browser at {endpoint}")
else:
# Launch new browser
browser_types = {
"chromium": self.playwright.chromium,
"firefox": self.playwright.firefox,
"webkit": self.playwright.webkit
}
launch_browser = browser_types.get(browser_type, self.playwright.chromium)
args = config.get("args", ['--no-sandbox', '--disable-setuid-sandbox'])
# Add extension paths if provided (Chromium only)
extension_paths = config.get("extension_paths", [])
if extension_paths and browser_type == "chromium":
args.append('--load-extension=' + ','.join(extension_paths))
self.browser = await launch_browser.launch(
headless=headless,
args=args
)
logger.info(f"Launched {browser_type} browser (headless={headless})")
return {"success": True, "browser": browser_type, "headless": headless, "mode": "attach" if attach else "launch"}
except Exception as e:
logger.error(f"Failed to launch/attach browser: {e}")
return {"success": False, "error": str(e)}
async def create_context(self, config: Dict = None) -> Dict[str, Any]:
"""Create a new browser context."""
if not self.browser:
return {"success": False, "error": "Browser not launched"}
config = config or {}
context_name = config.get("name")
async with self.lock:
try:
# Check for incognito
incognito = config.get("incognito", False)
if incognito:
ctx = await self.browser.new_context()
ctx_name = f"_incognito_{id(ctx)}"
else:
ctx = await self.browser.new_context()
ctx_name = context_name or f"ctx_{len(self.contexts)}"
self.contexts[ctx_name] = ctx
# Create initial page if requested
create_page = config.get("create_page", True)
page_id = None
if create_page:
page = await ctx.new_page()
page_id = f"page_{len(self.pages)}"
self.pages[page_id] = page
logger.info(f"Created context: {ctx_name} (incognito={incognito})")
return {
"success": True,
"context_name": ctx_name,
"page_id": page_id,
"incognito": incognito
}
except Exception as e:
logger.error(f"Failed to create context: {e}")
return {"success": False, "error": str(e)}
async def new_page(self, context_name: Optional[str] = None) -> Dict[str, Any]:
"""Create a new page in specified context (or default)."""
try:
ctx = await self._get_context(context_name)
page = await ctx.new_page()
page_id = f"page_{len(self.pages)}"
self.pages[page_id] = page
return {"success": True, "page_id": page_id, "context": context_name or "default"}
except Exception as e:
logger.error(f"Failed to create page: {e}")
return {"success": False, "error": str(e)}
# Navigation
async def navigate(self, page_id: str, url: str, wait_until: str = "load",
timeout: int = 30000) -> Dict[str, Any]:
"""Navigate to a URL."""
page = await self._get_page(page_id)
await page.goto(url, wait_until=wait_until, timeout=timeout)
return {
"success": True,
"url": page.url,
"title": await page.title()
}
# Interaction
async def click(self, page_id: str, selector: str, timeout: int = 30000) -> Dict[str, Any]:
"""Click an element."""
page = await self._get_page(page_id)
await page.click(selector, timeout=timeout)
return {"success": True, "selector": selector}
async def fill(self, page_id: str, selector: str, value: str, timeout: int = 30000) -> Dict[str, Any]:
"""Fill a form field."""
page = await self._get_page(page_id)
await page.fill(selector, value, timeout=timeout)
return {"success": True, "selector": selector, "value": value}
async def type_text(self, page_id: str, selector: str, text: str,
delay: int = 0, timeout: int = 30000) -> Dict[str, Any]:
"""Type text character by character."""
page = await self._get_page(page_id)
await page.type(selector, text, delay=delay, timeout=timeout)
return {"success": True, "selector": selector, "text": text}
async def wait_for_selector(self, page_id: str, selector: str,
state: str = "visible", timeout: int = 30000) -> Dict[str, Any]:
"""Wait for an element to be in a state."""
page = await self._get_page(page_id)
await page.wait_for_selector(selector, state=state, timeout=timeout)
return {"success": True, "selector": selector, "state": state}
# Script execution
async def execute_script(self, page_id: str, script: str) -> Dict[str, Any]:
"""Execute JavaScript in page context (no return)."""
page = await self._get_page(page_id)
await page.evaluate(script)
return {"success": True}
async def evaluate(self, page_id: str, expression: str, arg: Any = None) -> Dict[str, Any]:
"""Evaluate JavaScript and return result."""
page = await self._get_page(page_id)
result = await page.evaluate(expression, arg)
return {"success": True, "result": result}
# Inspection
async def screenshot(self, page_id: str, full_page: bool = False,
path: Optional[str] = None) -> Dict[str, Any]:
"""Take a screenshot."""
page = await self._get_page(page_id)
if path:
await page.screenshot(path=path, full_page=full_page)
return {"success": True, "path": path, "format": "png"}
else:
screenshot_bytes = await page.screenshot(full_page=full_page)
screenshot_b64 = base64.b64encode(screenshot_bytes).decode('utf-8')
return {"success": True, "screenshot": screenshot_b64, "format": "png", "encoding": "base64"}
async def get_content(self, page_id: str) -> Dict[str, Any]:
"""Get page HTML content."""
page = await self._get_page(page_id)
content = await page.content()
return {"success": True, "content": content}
async def get_title(self, page_id: str) -> Dict[str, Any]:
"""Get page title."""
page = await self._get_page(page_id)
title = await page.title()
return {"success": True, "title": title}
async def get_url(self, page_id: str) -> Dict[str, Any]:
"""Get current page URL."""
page = await self._get_page(page_id)
return {"success": True, "url": page.url}
# Cleanup
async def close_page(self, page_id: str) -> Dict[str, Any]:
"""Close a specific page."""
if page_id not in self.pages:
return {"success": False, "error": f"Page not found: {page_id}"}
page = self.pages[page_id]
await page.close()
del self.pages[page_id]
return {"success": True, "page_id": page_id}
async def close_context(self, context_name: str) -> Dict[str, Any]:
"""Close a named context and all its pages."""
if context_name not in self.contexts:
return {"success": False, "error": f"Context not found: {context_name}"}
ctx = self.contexts[context_name]
await ctx.close()
del self.contexts[context_name]
# Clean up any pages from this context
self.pages = {pid: p for pid, p in self.pages.items()
if p.context.name != context_name}
logger.info(f"Closed context: {context_name}")
return {"success": True, "context": context_name}
# Browser extension support
async def load_extension(self, extension_path: str) -> Dict[str, Any]:
"""Load a Chrome/Chromium unpacked extension."""
if not self.browser:
return {"success": False, "error": "Browser not launched"}
if not Path(extension_path).exists():
return {"success": False, "error": f"Extension path not found: {extension_path}"}
# Extension is loaded via args during browser launch, this reloads it
return {"success": False, "error": "Extension must be passed in launch config with 'extension_paths'"}
async def execute_extension_script(self, extension_id: str, script: str) -> Dict[str, Any]:
"""Execute script in extension context via CDP."""
# This would use CDP to inject into extension background page
return {"success": False, "error": "Use CDP layer directly with Runtime.evaluate"}
async def list_extensions(self) -> Dict[str, Any]:
"""List installed extensions via CDP."""
return {"success": False, "error": "Use CDP: Browser.getBrowserCommandLine and querying"}
async def close(self) -> Dict[str, Any]:
"""Close all contexts and the browser."""
await self.shutdown()
self.contexts = {}
self.pages = {}
self.default_context = None
self.browser = None
return {"success": True, "message": "Browser closed"}
# Listing
async def list_pages(self) -> Dict[str, Any]:
"""List all active pages."""
pages_info = []
for page_id, page in self.pages.items():
pages_info.append({
"page_id": page_id,
"url": page.url,
"title": await page.title()
})
return {"success": True, "pages": pages_info}
async def list_contexts(self) -> Dict[str, Any]:
"""List all contexts."""
contexts_info = []
for name, ctx in self.contexts.items():
contexts_info.append({
"name": name,
"pages_count": len(ctx.pages)
})
# Include default context if exists
if self.default_context:
contexts_info.append({
"name": "default",
"pages_count": len(self.default_context.pages)
})
return {"success": True, "contexts": contexts_info}
# Advanced APIs
async def playwright_command(self, page_id: str, command: str,
args: list = None, kwargs: dict = None) -> Dict[str, Any]:
"""Execute arbitrary Playwright API."""
args = args or []
kwargs = kwargs or {}
page = await self._get_page(page_id)
# Parse command path: "locator.click" -> page.locator(...).click(...)
parts = command.split(".")
obj = page
for i, part in enumerate(parts[:-1]):
# Get the attribute
attr = getattr(obj, part)
# If it's a method, we need to call it with an arg from args
# Pattern: part.method(args[i])
if callable(attr):
if args:
obj = attr(args[0])
args = args[1:]
else:
obj = attr()
else:
obj = attr
# Call final method
final_method = getattr(obj, parts[-1])
if callable(final_method):
result = await final_method(*args, **kwargs)
else:
result = final_method
return {"success": True, "result": result}
async def cdp_command(self, page_id: str, method: str, params: dict = None) -> Dict[str, Any]:
"""Send raw CDP command."""
page = await self._get_page(page_id)
cdp = await page.context.new_cdp_session(page)
result = await cdp.send(method, params or {})
return {"success": True, "result": result}
# Helpers
async def _get_context(self, context_name: Optional[str] = None) -> BrowserContext:
"""Get context by name or default."""
if context_name:
if context_name not in self.contexts:
raise ValueError(f"Context not found: {context_name}")
return self.contexts[context_name]
if not self.default_context:
self.default_context = await self.browser.new_context()
return self.default_context
async def _get_page(self, page_id: str) -> Page:
"""Get page by ID."""
if page_id not in self.pages:
raise ValueError(f"Page not found: {page_id}")
return self.pages[page_id]
#!/bin/bash
# Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyleft: this program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# Hermes Node Agent Installer — Linux Version
# Copyright (c) 2026 Stefy (nextime) Lanza <stefy@nexlab.net>
# All rights reserved.
#
# This software is released under the MIT License with a copyleft clause.
# See the LICENSE file for full terms.
#
# INSTALLER DESCRIPTION:
# Self-contained installer for Linux. Embedded: node agent code, init.d script.
# External: pip install websockets (online required).
# No git clone, no network downloads of our files.
set -e
echo "=== Hermes Node Agent Installer (Linux) ==="
echo ""
# Check if running as root (for service setup)
if [ "$EUID" -eq 0 ]; then
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 " Install Python 3 first, then re-run this installer."
exit 1
fi
echo "✓ Python: $(python3 --version)"
# Check for pip (will use it to install websockets)
if ! command -v pip3 &> /dev/null && ! command -v pip &> /dev/null; then
echo "❌ ERROR: pip is required but not found."
echo " Install with: apt install python3-pip (Debian/Ubuntu) or equivalent"
exit 1
fi
PIP_CMD="pip3"
command -v pip3 &> /dev/null || PIP_CMD="pip"
# Install websockets library (only network call)
echo "[1/5] Installing Python dependencies (websockets)..."
# Check if already installed
if python3 -c "import websockets" 2>/dev/null; then
echo "✓ websockets library already installed"
else
if ! $PIP_CMD install --quiet websockets 2>/dev/null; then
echo "⚠️ Could not install via pip (may need network). Trying with user flag..."
if ! $PIP_CMD install --user --quiet websockets 2>/dev/null; then
echo "❌ Failed to install websockets. Try manually: $PIP_CMD install websockets"
exit 1
fi
fi
echo "✓ websockets library installed"
fi
# Determine install locations
if [ "$RUN_AS_ROOT" = true ]; then
AGENT_DIR="/usr/local/bin"
CONFIG_DIR="/etc/hermes-node"
USE_SERVICE=true
else
AGENT_DIR="$HOME/.local/bin"
CONFIG_DIR="$HOME/.config/hermes-node"
USE_SERVICE=false
fi
mkdir -p "$AGENT_DIR"
mkdir -p "$CONFIG_DIR"
# Extract embedded agent files
echo "[2/5] Extracting embedded agent files..."
TMP_EXTRACT=$(mktemp -d)
ORIG_PWD=$(pwd)
cd "$TMP_EXTRACT"
# === EMBEDDED TARBALL (base64) ===
cat <<'TARBALL_DATA' | base64 -d | tar xzf -
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
TARBALL_DATA
# Copy agent script
cp hermes-node-agent/hermes_node_agent.py "$AGENT_DIR/hermes-node-agent"
chmod +x "$AGENT_DIR/hermes-node-agent"
echo "✓ Agent: $AGENT_DIR/hermes-node-agent"
# Copy init.d script (for root installs)
if [ "$USE_SERVICE" = true ]; then
cp hermes-node-agent/hermes-node-agent.init.d /etc/init.d/hermes-node-agent
chmod +x /etc/init.d/hermes-node-agent
echo "✓ Init script: /etc/init.d/hermes-node-agent"
fi
# Copy node_gateway.py (for plugin reference, if needed)
cp hermes-node-agent/node_gateway.py "$AGENT_DIR/hermes-node-gateway.py" 2>/dev/null || true
# Run packaged interactive config installer
if [ -f "$TMP_EXTRACT/hermes-node-agent/install.sh" ]; then
chmod +x "$TMP_EXTRACT/hermes-node-agent/install.sh"
mkdir -p "$CONFIG_DIR"
echo "[4/5] Running interactive configuration installer..."
INSTALLER_TMP_ROOT=$(mktemp -d)
trap 'rm -rf "$INSTALLER_TMP_ROOT" "$TMP_EXTRACT"' EXIT
mkdir -p "$INSTALLER_TMP_ROOT/node-agent"
cp "$TMP_EXTRACT/hermes-node-agent/hermes_node_agent.py" "$INSTALLER_TMP_ROOT/node-agent/hermes_node_agent.py"
cp "$TMP_EXTRACT/hermes-node-agent/browser_controller.py" "$INSTALLER_TMP_ROOT/node-agent/browser_controller.py"
cp "$TMP_EXTRACT/hermes-node-agent/requirements.txt" "$INSTALLER_TMP_ROOT/node-agent/requirements.txt"
cp "$TMP_EXTRACT/hermes-node-agent/install.sh" "$INSTALLER_TMP_ROOT/node-agent/install.sh"
cp "$TMP_EXTRACT/hermes-node-agent/hermes-node-agent.init.d" "$INSTALLER_TMP_ROOT/node-agent/hermes-node-agent.init.d"
cp "$TMP_EXTRACT/hermes-node-agent/hermes-node-agent.service" "$INSTALLER_TMP_ROOT/node-agent/hermes-node-agent.service"
cp "$TMP_EXTRACT/hermes-node-agent/README.md" "$INSTALLER_TMP_ROOT/node-agent/README.md"
cp "$TMP_EXTRACT/hermes-node-agent/LICENSE" "$INSTALLER_TMP_ROOT/node-agent/LICENSE"
cp "$TMP_EXTRACT/hermes-node-agent/DEPLOYMENT.md" "$INSTALLER_TMP_ROOT/node-agent/DEPLOYMENT.md"
cp "$TMP_EXTRACT/hermes-node-agent/PROTOCOL.md" "$INSTALLER_TMP_ROOT/node-agent/PROTOCOL.md"
cp "$TMP_EXTRACT/hermes-node-agent/BROWSER_PROTOCOL.md" "$INSTALLER_TMP_ROOT/node-agent/BROWSER_PROTOCOL.md"
(
cd "$INSTALLER_TMP_ROOT"
bash ./node-agent/install.sh
)
rm -rf "$INSTALLER_TMP_ROOT"
trap 'rm -rf "$TMP_EXTRACT"' EXIT
else
echo "❌ ERROR: install.sh missing from embedded payload"
exit 1
fi
echo "[3/5] Cleaning up temporary files..."
cd "$ORIG_PWD"
rm -rf "$TMP_EXTRACT"
trap - EXIT
# Install SysV init service (root only)
if [ "$USE_SERVICE" = true ]; then
echo ""
echo "[Service] Enabling auto-start on boot..."
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 ""
echo "[Service] Skipped (not root). Manual start required:"
echo " $AGENT_DIR/hermes-node-agent --config $CONFIG_DIR/config.json"
fi
echo ""
echo "=== Installation Complete ==="
echo ""
echo "Quick start:"
echo " Config: $CONFIG_DIR/config.json"
echo " Agent: $AGENT_DIR/hermes-node-agent"
echo " Run: $AGENT_DIR/hermes-node-agent --config $CONFIG_DIR/config.json"
echo ""
if [ "$USE_SERVICE" = true ]; then
echo "To start the service:"
echo " /etc/init.d/hermes-node-agent start"
echo ""
else
echo "To run manually:"
echo " $AGENT_DIR/hermes-node-agent --config $CONFIG_DIR/config.json &"
echo ""
fi
#!/bin/sh
# /etc/init.d/hermes-node-agent
# SysVinit script for Hermes Node Agent
#
# chkconfig: 2345 95 05
# description: Hermes Node Agent reverse-connected WebSocket client
### BEGIN INIT INFO
# Provides: hermes-node-agent
# Required-Start: $network $remote_fs $syslog
# Required-Stop: $network $remote_fs $syslog
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: Hermes Node Agent
# Description: Reverse-connected WebSocket node agent.
### END INIT INFO
NAME="hermes-node-agent"
DAEMON="/usr/bin/python3"
SCRIPT_DIR="/usr/local/bin"
DAEMON_SCRIPT="${SCRIPT_DIR}/hermes_node_agent.py"
PIDFILE="/var/run/${NAME}.pid"
LOGFILE="/var/log/${NAME}.log"
USER="root"
GROUP="root"
# Check daemon exists
if [ ! -x "$DAEMON" ]; then
echo "$DAEMON not found or not executable."
exit 5
fi
if [ ! -f "$DAEMON_SCRIPT" ]; then
echo "$DAEMON_SCRIPT not found."
exit 5
fi
# Ensure config exists
if [ ! -f "/etc/hermes-node/config.json" ]; then
echo "/etc/hermes-node/config.json not found."
exit 6
fi
. /lib/lsb/init-functions 2>/dev/null || true
start() {
echo "Starting $NAME..."
if [ -f "$PIDFILE" ]; then
PID=$(cat "$PIDFILE")
if kill -0 "$PID" 2>/dev/null; then
echo "$NAME is already running (PID $PID)."
return 0
else
rm -f "$PIDFILE"
fi
fi
touch "$LOGFILE"
chown "$USER:$GROUP" "$LOGFILE" 2>/dev/null || chmod 644 "$LOGFILE"
$DAEMON $DAEMON_SCRIPT >> "$LOGFILE" 2>&1 &
echo $! > "$PIDFILE"
sleep 1
if kill -0 $(cat "$PIDFILE") 2>/dev/null; then
echo "$NAME started (PID $(cat $PIDFILE))."
else
echo "$NAME failed to start. Check $LOGFILE"
exit 1
fi
}
stop() {
echo "Stopping $NAME..."
if [ -f "$PIDFILE" ]; then
PID=$(cat "$PIDFILE")
if kill -0 "$PID" 2>/dev/null; then
kill "$PID" 2>/dev/null
for i in $(seq 1 30); do
if ! kill -0 "$PID" 2>/dev/null; then
break
fi
sleep 0.5
done
if kill -0 "$PID" 2>/dev/null; then
echo "Force killing..."
kill -9 "$PID" 2>/dev/null
sleep 1
fi
fi
rm -f "$PIDFILE"
echo "$NAME stopped."
else
echo "$NAME is not running."
fi
pkill -f "hermes_node_agent.py" 2>/dev/null || true
}
case "$1" in
start)
start
;;
stop)
stop
;;
restart)
stop
sleep 1
start
;;
reload|force-reload)
echo "Reload not supported, restarting..."
stop
sleep 1
start
;;
status)
RUNNING=0
if [ -f "$PIDFILE" ]; then
PID=$(cat "$PIDFILE")
if kill -0 "$PID" 2>/dev/null; then
echo "$NAME is running (PID $PID)."
RUNNING=1
else
echo "$NAME is not running (stale PID file)."
RUNNING=0
fi
else
PID=$(pgrep -f "hermes_node_agent.py" | head -1)
if [ -n "$PID" ]; then
echo "$NAME is running (PID $PID) but no PID file."
RUNNING=1
else
echo "$NAME is not running."
RUNNING=0
fi
fi
exit $(( 1 - RUNNING ))
;;
*)
echo "Usage: $0 {start|stop|restart|status}"
exit 1
;;
esac
exit 0
# Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyleft: this program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# Hermes Node Protocol
# Copyright (c) 2026 Stefy (nextime) Lanza <stefy@nexlab.net>
# All rights reserved.
#
# This software is released under the MIT License with a copyleft clause.
# See the LICENSE file for full terms.
[Unit]
Description=Hermes Node Agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/local/bin/hermes-node-agent
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/tmp
[Install]
WantedBy=default.target
#!/usr/bin/env python3
# Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyleft: this program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# Hermes Node Protocol
# Copyright (c) 2026 Stefy (nextime) Lanza <stefy@nexlab.net>
# All rights reserved.
#
# This software is released under the MIT License with a copyleft clause.
# See the LICENSE file for full terms.
"""
Hermes Node Agent - Reverse-connection node executor
Connects to Hermes Gateway via WebSocket and executes commands.
Supports optional tools: browser control, computer_control.
Author: Lisa (Hermes AI)
Date: 2026-04-30 (enhanced)
"""
import argparse
import asyncio
import base64
import json
import logging
import os
import shutil
import shlex
import ssl
import subprocess
import sys
import time
from pathlib import Path
from typing import Optional, Dict, Any, List
LOG_PREVIEW_LEN = 120
def _preview_command(command: Any, limit: int = LOG_PREVIEW_LEN) -> str:
"""Return a compact single-line preview for logging."""
if isinstance(command, (list, tuple)):
text = ' '.join(str(part) for part in command)
else:
text = str(command)
text = ' '.join(text.split())
if len(text) > limit:
return text[:limit] + '…'
return text
def _setup_logging(debug: bool = False) -> None:
level = logging.DEBUG if debug else logging.INFO
logging.basicConfig(level=level, format='%(message)s')
def _log_start(node_name: str, tools: list) -> None:
logger.info(f"start ▶ {node_name} — {', '.join(tools)}")
def _log_connect(url: str) -> None:
logger.info(f"connect ▶ {url}")
def _log_tls_disabled() -> None:
logger.info("tls verify disabled")
def _log_connected() -> None:
logger.info("connect ✓")
def _log_disconnected(reason: Any = None) -> None:
if reason:
logger.info(f"disconnect — {reason}")
else:
logger.info("disconnect")
def _log_registering(node_name: str) -> None:
logger.info(f"register ▶ {node_name}")
def _log_registered(node_name: str) -> None:
logger.info(f"register ✓ {node_name}")
def _log_waiting() -> None:
logger.info("waiting for commands")
def _log_exec_received(command: Any) -> None:
logger.info(f"exec ▶ {_preview_command(command)}")
def _log_exec_completed(command: Any, exit_code: Any) -> None:
logger.info(f"exec ✓ exit={exit_code} — {_preview_command(command)}")
def _log_exec_failed(command: Any, error: Any, exit_code: Any = None) -> None:
prefix = f"exec ✗ exit={exit_code}" if exit_code is not None else "exec ✗"
logger.error(f"{prefix} — {_preview_command(command)} — {error}")
def _log_tool_completed(tool_name: str, label: Any, success: bool, error: Any = None) -> None:
mark = '✓' if success else '✗'
suffix = f" — {error}" if error else ''
logger.info(f"{tool_name} {mark} {_preview_command(label)}{suffix}")
def _log_browser_received(command: Any) -> None:
logger.info(f"browser ▶ {_preview_command(command)}")
def _log_cc_received(action: Any) -> None:
logger.info(f"computer ▶ {_preview_command(action)}")
def _log_audio_received(action: Any) -> None:
logger.info(f"audio ▶ {_preview_command(action)}")
def _log_camera_received(action: Any) -> None:
logger.info(f"camera ▶ {_preview_command(action)}")
def _log_reconnect(delay: Any, reason: Any) -> None:
logger.info(f"reconnect in {delay}s — {reason}")
def _log_registration_ack() -> None:
logger.debug("register ack")
def _log_heartbeat_ack() -> None:
logger.debug("heartbeat ack")
def _log_unknown_message(req_type: Any) -> None:
logger.warning(f"unknown message: {req_type}")
def _log_connection_error(message: Any) -> None:
logger.error(f"connection error — {message}")
def _log_config_missing(path: Path) -> None:
logger.error(f"config missing — {path}")
def _log_token_missing() -> None:
logger.error("token missing in config")
def _log_init_warning(component: str, message: Any) -> None:
logger.warning(f"{component} init failed — {message}")
def _log_disabled(component: str, message: str) -> None:
logger.warning(f"{component} disabled — {message}")
def _log_shutdown() -> None:
logger.info("shutdown")
logger = logging.getLogger(__name__)
try:
import websockets
except ImportError:
print("ERROR: websockets library not found. Install with: pip install websockets")
sys.exit(1)
try:
from browser_controller import BrowserController
HAS_BROWSER = True
except ImportError:
HAS_BROWSER = False
logger = logging.getLogger(__name__)
# ═════════════════════════════════════════════════════════════════════════
# DEFAULT CONFIGURATION
# ═════════════════════════════════════════════════════════════════════════
DEFAULT_GATEWAY_TOKEN = 'GATEWAY_TOKEN_MUST_BE_PROVIDED'
DEFAULT_CONFIG = {
'gateway_url': 'wss://localhost:8765',
'node_name': 'unknown',
'token': DEFAULT_GATEWAY_TOKEN,
'reconnect_interval': 5,
'heartbeat_interval': 30,
'gateway_cert_path': None,
'capabilities': ['exec'],
'enable_browser': False,
'enable_computer_control': False,
'enable_desktop_observe': False,
'enable_audio_control': False,
'enable_camera_control': False,
}
# ═════════════════════════════════════════════════════════════════════════
# 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: Any, approved: bool = False) -> Dict[str, Any]:
"""Execute command respecting permission rules."""
raise NotImplementedError
def _normalize_command_text(self, command: Any) -> str:
if isinstance(command, (list, tuple)):
return ' '.join(str(part) for part in command).strip()
return str(command).strip()
def _check_permission(self, command: Any, approved: bool) -> tuple[bool, str]:
"""Check allow/deny/ask rules.
Returns (allowed, reason). 'ask' means requires approval gate.
Accepts command as string or argv list.
"""
import re
cmd = self._normalize_command_text(command)
# 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 using integrated permission checks and /bin/sh."""
def execute(self, command: Any, 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 isinstance(command, (list, tuple)):
cmd = ' '.join(shlex.quote(str(part)) for part in command)
else:
cmd = str(command)
try:
proc = subprocess.Popen(
['/bin/sh', '-c', cmd],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
out, err = proc.communicate(timeout=300)
return {
'success': proc.returncode == 0,
'stdout': out,
'stderr': err,
'exit_code': proc.returncode,
}
except subprocess.TimeoutExpired:
try:
proc.kill()
except Exception:
pass
return {'success': False, 'error': 'Command timed out', 'exit_code': 124}
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)}
# ── AUDIO CONTROL ──────────────────────────────────────────────────────────
class AudioControllerBase:
"""Base class for audio device/media actions."""
def capability_info(self) -> Dict[str, Any]:
raise NotImplementedError
def list_audio_devices(self) -> Dict[str, Any]:
raise NotImplementedError
def get_audio_status(self) -> Dict[str, Any]:
raise NotImplementedError
def capture_output(self, params: Dict[str, Any]) -> Dict[str, Any]:
raise NotImplementedError
def capture_input(self, params: Dict[str, Any]) -> Dict[str, Any]:
raise NotImplementedError
def play_audio(self, params: Dict[str, Any]) -> Dict[str, Any]:
raise NotImplementedError
class CameraControllerBase:
"""Base class for camera device/media actions."""
def capability_info(self) -> Dict[str, Any]:
raise NotImplementedError
def list_cameras(self) -> Dict[str, Any]:
raise NotImplementedError
def get_camera_status(self) -> Dict[str, Any]:
raise NotImplementedError
def capture_frame(self, params: Dict[str, Any]) -> Dict[str, Any]:
raise NotImplementedError
def capture_video(self, params: Dict[str, Any]) -> Dict[str, Any]:
raise NotImplementedError
class PosixAudioController(AudioControllerBase):
"""Linux audio support via ffmpeg + available host backends."""
def __init__(self):
self.ffmpeg = shutil.which('ffmpeg')
if not self.ffmpeg:
raise PlatformError('ffmpeg not found')
self.ffplay = shutil.which('ffplay')
self.ffprobe = shutil.which('ffprobe')
self.pactl = shutil.which('pactl')
self.arecord = shutil.which('arecord')
self.aplay = shutil.which('aplay')
self.backend = self._detect_backend()
def _detect_backend(self) -> str:
if self.pactl:
probe = self._run_quiet([self.pactl, 'info'])
if probe['success']:
server = (probe.get('stdout') or '').lower()
if 'pipewire' in server:
return 'pipewire-pulse'
return 'pulseaudio'
if os.environ.get('PIPEWIRE_RUNTIME_DIR') or os.environ.get('XDG_RUNTIME_DIR'):
return 'pipewire'
if self.arecord:
return 'alsa'
return 'unknown'
def capability_info(self) -> Dict[str, Any]:
monitor_ready, monitor_name = self._default_monitor_source()
input_ready, input_source = self._default_input_source()
return {
'platform': 'linux',
'backend': self.backend,
'available': True,
'can_capture_output': monitor_ready,
'can_capture_input': input_ready,
'can_play_audio': bool(self.ffplay or self.aplay or self.ffmpeg),
'can_inject_mic': False,
'capture_output_ready': monitor_ready,
'capture_output_backend': 'pulseaudio-monitor' if monitor_ready else None,
'default_output_monitor': monitor_name,
'default_input_source': input_source,
'ffmpeg': bool(self.ffmpeg),
'ffplay': bool(self.ffplay),
'pactl': bool(self.pactl),
'arecord': bool(self.arecord),
'aplay': bool(self.aplay),
}
def _run_quiet(self, cmd: List[str], timeout: int = 15) -> Dict[str, Any]:
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return {
'success': proc.returncode == 0,
'stdout': proc.stdout,
'stderr': proc.stderr,
'exit_code': proc.returncode,
}
except Exception as e:
return {'success': False, 'stdout': '', 'stderr': str(e), 'exit_code': -1}
def _default_output_sink(self) -> Optional[str]:
if not self.pactl:
return None
result = self._run_quiet([self.pactl, 'get-default-sink'])
sink = (result.get('stdout') or '').strip()
if result['success'] and sink:
return sink
return None
def _default_input_source(self) -> tuple[bool, Optional[str]]:
if self.pactl:
result = self._run_quiet([self.pactl, 'get-default-source'])
source = (result.get('stdout') or '').strip()
if result['success'] and source:
return True, source
if self.arecord:
return True, 'default'
return False, None
def _default_monitor_source(self) -> tuple[bool, Optional[str]]:
sink = self._default_output_sink()
if sink:
return True, f'{sink}.monitor'
return False, None
def _expand_output_path(self, path: Optional[str], suffix: str) -> str:
if path:
return str(Path(path).expanduser())
stamp = int(time.time())
return f'/tmp/hermes-audio-{stamp}{suffix}'
def _encode_file(self, path: str) -> Dict[str, Any]:
data = Path(path).read_bytes()
return {
'path': path,
'size_bytes': len(data),
'data_base64': base64.b64encode(data).decode('ascii'),
}
def _media_duration(self, path: str) -> Optional[float]:
if not self.ffprobe:
return None
result = self._run_quiet([
self.ffprobe, '-v', 'error', '-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1', path
])
if not result['success']:
return None
try:
return round(float((result.get('stdout') or '').strip()), 3)
except Exception:
return None
def list_audio_devices(self) -> Dict[str, Any]:
devices: Dict[str, Any] = {'backend': self.backend, 'sinks': [], 'sources': []}
if self.pactl:
sink_res = self._run_quiet([self.pactl, 'list', 'short', 'sinks'])
source_res = self._run_quiet([self.pactl, 'list', 'short', 'sources'])
if sink_res['success']:
for line in sink_res.get('stdout', '').splitlines():
parts = line.split('\t')
if len(parts) >= 2:
devices['sinks'].append({'id': parts[0], 'name': parts[1], 'raw': line})
if source_res['success']:
for line in source_res.get('stdout', '').splitlines():
parts = line.split('\t')
if len(parts) >= 2:
devices['sources'].append({'id': parts[0], 'name': parts[1], 'raw': line})
if not devices['sources'] and self.arecord:
src = self._run_quiet([self.arecord, '-l'])
devices['sources_raw'] = src.get('stdout', '')
return {'success': True, **devices}
def get_audio_status(self) -> Dict[str, Any]:
monitor_ready, monitor_name = self._default_monitor_source()
input_ready, input_source = self._default_input_source()
status = {
'success': True,
'backend': self.backend,
'default_output_sink': self._default_output_sink(),
'default_output_monitor': monitor_name,
'default_input_source': input_source,
'capture_output_ready': monitor_ready,
'capture_input_ready': input_ready,
'can_play_audio': bool(self.ffplay or self.aplay or self.ffmpeg),
}
if self.pactl:
info = self._run_quiet([self.pactl, 'info'])
if info['success']:
status['server_info'] = info.get('stdout', '')
return status
def capture_output(self, params: Dict[str, Any]) -> Dict[str, Any]:
duration = max(1, min(int(params.get('duration', 5)), 600))
fmt = str(params.get('format', 'wav')).lower()
path = self._expand_output_path(params.get('output_path') or params.get('path'), f'.{fmt}')
monitor_ready, monitor = self._default_monitor_source()
if not monitor_ready or not monitor:
return {'success': False, 'error': 'No PulseAudio/PipeWire monitor source available for output capture'}
cmd = [
self.ffmpeg, '-y', '-v', 'error', '-f', 'pulse', '-i', monitor,
'-t', str(duration), path,
]
result = self._run_quiet(cmd, timeout=duration + 15)
if not result['success']:
return {'success': False, 'error': (result.get('stderr') or result.get('stdout') or 'ffmpeg capture failed').strip()}
payload = {
'success': True,
'format': fmt,
'duration': duration,
'source': monitor,
}
payload.update(self._encode_file(path))
media_duration = self._media_duration(path)
if media_duration is not None:
payload['measured_duration'] = media_duration
return payload
def capture_input(self, params: Dict[str, Any]) -> Dict[str, Any]:
duration = max(1, min(int(params.get('duration', 5)), 600))
fmt = str(params.get('format', 'wav')).lower()
path = self._expand_output_path(params.get('output_path') or params.get('path'), f'.{fmt}')
source = params.get('source')
input_ready, default_source = self._default_input_source()
if not source:
source = default_source
if self.pactl and source:
cmd = [
self.ffmpeg, '-y', '-v', 'error', '-f', 'pulse', '-i', str(source),
'-t', str(duration), path,
]
result = self._run_quiet(cmd, timeout=duration + 15)
elif self.arecord and input_ready:
cmd = [self.arecord, '-q', '-d', str(duration), path]
result = self._run_quiet(cmd, timeout=duration + 15)
else:
return {'success': False, 'error': 'No usable input capture backend available'}
if not result['success']:
return {'success': False, 'error': (result.get('stderr') or result.get('stdout') or 'input capture failed').strip()}
payload = {
'success': True,
'format': fmt,
'duration': duration,
'source': source,
}
payload.update(self._encode_file(path))
media_duration = self._media_duration(path)
if media_duration is not None:
payload['measured_duration'] = media_duration
return payload
def play_audio(self, params: Dict[str, Any]) -> Dict[str, Any]:
path = params.get('path')
if not path:
return {'success': False, 'error': 'play_audio requires params.path'}
path = str(Path(path).expanduser())
if not Path(path).exists():
return {'success': False, 'error': f'Audio file not found: {path}'}
if self.ffplay:
cmd = [self.ffplay, '-nodisp', '-autoexit', '-loglevel', 'error', path]
elif self.aplay:
cmd = [self.aplay, path]
else:
cmd = [self.ffmpeg, '-v', 'error', '-i', path, '-f', 'null', '-']
result = self._run_quiet(cmd, timeout=max(30, int(params.get('timeout', 120))))
if not result['success']:
return {'success': False, 'error': (result.get('stderr') or result.get('stdout') or 'audio playback failed').strip()}
payload = {'success': True, 'path': path}
media_duration = self._media_duration(path)
if media_duration is not None:
payload['duration'] = media_duration
return payload
# ── 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)
return PosixCommandExecutor(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
def make_audio_controller(config: Dict[str, Any]) -> Optional[AudioControllerBase]:
if not config.get('enable_audio_control'):
return None
if is_linux():
try:
return PosixAudioController()
except Exception as e:
_log_disabled('audio_control', str(e))
return None
_log_disabled('audio_control', f'unsupported platform: {sys.platform}')
return None
class PosixCameraController(CameraControllerBase):
"""Linux camera support via ffmpeg + V4L2 device nodes."""
def __init__(self):
self.ffmpeg = shutil.which('ffmpeg')
if not self.ffmpeg:
raise PlatformError('ffmpeg not found')
self.ffprobe = shutil.which('ffprobe')
self.v4l2_ctl = shutil.which('v4l2-ctl')
def _list_device_paths(self) -> List[Path]:
return sorted(Path('/dev').glob('video*'), key=lambda p: p.name)
def _encode_file(self, path: str) -> Dict[str, Any]:
data = Path(path).read_bytes()
return {
'path': path,
'size_bytes': len(data),
'data_base64': base64.b64encode(data).decode('ascii'),
}
def _media_duration(self, path: str) -> Optional[float]:
if not self.ffprobe:
return None
try:
proc = subprocess.run([
self.ffprobe, '-v', 'error', '-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1', path
], capture_output=True, text=True, timeout=15)
if proc.returncode != 0:
return None
return round(float((proc.stdout or '').strip()), 3)
except Exception:
return None
def _expand_output_path(self, path: Optional[str], suffix: str) -> str:
if path:
return str(Path(path).expanduser())
stamp = int(time.time())
return f'/tmp/hermes-camera-{stamp}{suffix}'
def _ffmpeg_probe(self, device: str) -> Dict[str, Any]:
try:
proc = subprocess.run(
[self.ffmpeg, '-hide_banner', '-f', 'v4l2', '-list_formats', 'all', '-i', device],
capture_output=True,
text=True,
timeout=15,
)
text = '\n'.join(part for part in [proc.stdout, proc.stderr] if part)
return {
'success': proc.returncode in (0, 1),
'output': text.strip(),
'exit_code': proc.returncode,
}
except Exception as e:
return {'success': False, 'output': str(e), 'exit_code': -1}
def _device_info(self, device_path: Path) -> Dict[str, Any]:
info = {
'path': str(device_path),
'name': device_path.name,
'exists': device_path.exists(),
'readable': os.access(device_path, os.R_OK),
'writable': os.access(device_path, os.W_OK),
}
by_id_root = Path('/dev/v4l/by-id')
aliases = []
if by_id_root.exists():
for alias in sorted(by_id_root.iterdir()):
try:
if alias.resolve() == device_path.resolve():
aliases.append(str(alias))
except Exception:
continue
if aliases:
info['aliases'] = aliases
if self.v4l2_ctl:
try:
proc = subprocess.run(
[self.v4l2_ctl, '--device', str(device_path), '--all'],
capture_output=True,
text=True,
timeout=15,
)
info['details'] = (proc.stdout or proc.stderr or '').strip()
info['available'] = proc.returncode == 0
except Exception as e:
info['available'] = False
info['probe_error'] = str(e)
else:
probe = self._ffmpeg_probe(str(device_path))
info['available'] = probe['success']
if probe.get('output'):
info['details'] = probe['output']
return info
def capability_info(self) -> Dict[str, Any]:
devices = self._list_device_paths()
return {
'platform': 'linux',
'backend': 'v4l2-ffmpeg',
'available': bool(devices),
'device_count': len(devices),
'supports_frame_capture': True,
'supports_video_capture': True,
'ffmpeg': bool(self.ffmpeg),
'ffprobe': bool(self.ffprobe),
'v4l2_ctl': bool(self.v4l2_ctl),
'devices': [str(p) for p in devices],
}
def list_cameras(self) -> Dict[str, Any]:
devices = [self._device_info(path) for path in self._list_device_paths()]
return {
'success': True,
'backend': 'v4l2-ffmpeg',
'camera_count': len(devices),
'cameras': devices,
}
def get_camera_status(self) -> Dict[str, Any]:
devices = self.list_cameras()
payload = {
'success': True,
'backend': 'v4l2-ffmpeg',
'ffmpeg': bool(self.ffmpeg),
'ffprobe': bool(self.ffprobe),
'v4l2_ctl': bool(self.v4l2_ctl),
'camera_count': devices.get('camera_count', 0),
'cameras': devices.get('cameras', []),
}
if payload['camera_count'] == 0:
payload['available'] = False
payload['reason'] = 'No /dev/video* devices found'
else:
payload['available'] = True
return payload
def _pick_device(self, params: Dict[str, Any]) -> str:
device = params.get('device') or params.get('device_path')
if device:
return str(Path(str(device)).expanduser())
devices = self._list_device_paths()
if not devices:
raise PlatformError('No /dev/video* devices found')
return str(devices[0])
def capture_frame(self, params: Dict[str, Any]) -> Dict[str, Any]:
device = self._pick_device(params)
fmt = str(params.get('format', 'png')).lower()
if fmt not in ('png', 'jpg', 'jpeg', 'bmp'):
return {'success': False, 'error': f'Unsupported frame format: {fmt}'}
suffix = '.jpg' if fmt == 'jpeg' else f'.{fmt}'
path = self._expand_output_path(params.get('output_path') or params.get('path'), suffix)
width = params.get('width')
height = params.get('height')
cmd = [self.ffmpeg, '-y', '-v', 'error', '-f', 'v4l2']
if width and height:
cmd += ['-video_size', f'{int(width)}x{int(height)}']
cmd += ['-i', device, '-frames:v', '1', path]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
except Exception as e:
return {'success': False, 'error': str(e)}
if proc.returncode != 0:
return {'success': False, 'error': (proc.stderr or proc.stdout or 'frame capture failed').strip()}
payload = {
'success': True,
'device': device,
'format': fmt,
}
payload.update(self._encode_file(path))
return payload
def capture_video(self, params: Dict[str, Any]) -> Dict[str, Any]:
device = self._pick_device(params)
duration = max(1, min(int(params.get('duration', 5)), 600))
fmt = str(params.get('format', 'mp4')).lower()
extension = 'mkv' if fmt == 'matroska' else fmt
if extension not in ('mp4', 'mkv', 'webm'):
return {'success': False, 'error': f'Unsupported video format: {fmt}'}
path = self._expand_output_path(params.get('output_path') or params.get('path'), f'.{extension}')
width = params.get('width')
height = params.get('height')
fps = params.get('fps')
cmd = [self.ffmpeg, '-y', '-v', 'error', '-f', 'v4l2']
if fps:
cmd += ['-framerate', str(fps)]
if width and height:
cmd += ['-video_size', f'{int(width)}x{int(height)}']
cmd += ['-i', device, '-t', str(duration), path]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=duration + 30)
except Exception as e:
return {'success': False, 'error': str(e)}
if proc.returncode != 0:
return {'success': False, 'error': (proc.stderr or proc.stdout or 'video capture failed').strip()}
payload = {
'success': True,
'device': device,
'format': extension,
'duration': duration,
}
payload.update(self._encode_file(path))
media_duration = self._media_duration(path)
if media_duration is not None:
payload['measured_duration'] = media_duration
return payload
def make_camera_controller(config: Dict[str, Any]) -> Optional[CameraControllerBase]:
if not config.get('enable_camera_control'):
return None
if is_linux():
try:
controller = PosixCameraController()
if not controller._list_device_paths():
_log_disabled('camera_control', 'no /dev/video* devices found')
return None
return controller
except Exception as e:
_log_disabled('camera_control', str(e))
return None
_log_disabled('camera_control', f'unsupported platform: {sys.platform}')
return None
class NodeAgent:
def __init__(self, config_path: Optional[str] = None):
self.config = self._load_config(config_path)
self.executor = make_executor(self.config)
self.computer = make_computer_controller(self.config)
self.audio = make_audio_controller(self.config)
self.camera = make_camera_controller(self.config)
self.browser = None
if self.config.get('enable_browser') and HAS_BROWSER:
try:
self.browser = BrowserController()
except Exception as e:
logger.warning(f"BrowserController init failed: {e}")
self.capabilities = self._detect_capabilities()
def _load_config(self, path: Optional[str]) -> Dict[str, Any]:
"""Load node configuration from JSON."""
cfg_path = Path(path).expanduser() if path else Path.home() / '.config' / 'hermes-node' / 'config.json'
if not cfg_path.exists():
logger.error(f"Config not found: {cfg_path}")
logger.error(f"Run the installer or copy config-template.json to {cfg_path}")
sys.exit(1)
try:
with open(cfg_path) as f:
data = json.load(f)
except json.JSONDecodeError as e:
logger.error(f"Config file is not valid JSON: {e}")
logger.error(f"File: {cfg_path}")
sys.exit(1)
# Merge defaults
merged = {**DEFAULT_CONFIG, **data}
if not merged['token']:
logger.error("Token missing from config")
sys.exit(1)
return merged
def _detect_capabilities(self) -> Dict[str, Any]:
"""Detect which optional tools are available on this machine."""
caps = {
'enable_browser': self.config.get('enable_browser', False),
'enable_computer_control': self.config.get('enable_computer_control', False),
'enable_desktop_observe': self.config.get('enable_desktop_observe', False),
'enable_audio_control': self.config.get('enable_audio_control', False),
'enable_camera_control': self.config.get('enable_camera_control', False),
}
if self.browser is not None:
caps['browser_control'] = {'available': True}
if self.computer is not None:
cc_info = {
'display': os.environ.get('DISPLAY', ':0'),
'has_xdotool': subprocess.run(['which', 'xdotool'], capture_output=True).returncode == 0,
'has_import': subprocess.run(['which', 'import'], capture_output=True).returncode == 0,
'has_scrot': subprocess.run(['which', 'scrot'], capture_output=True).returncode == 0,
}
caps['computer_control'] = cc_info
if caps['enable_desktop_observe']:
caps['desktop_observe'] = {
'available': self.computer is not None,
'display': os.environ.get('DISPLAY', ':0')
}
if caps['enable_audio_control']:
if self.audio is not None:
caps['audio_control'] = self.audio.capability_info()
else:
caps['audio_control'] = {
'available': False,
'reason': 'audio control requested but backend dependencies are unavailable'
}
if caps['enable_camera_control']:
if self.camera is not None:
caps['camera_control'] = self.camera.capability_info()
else:
caps['camera_control'] = {
'available': False,
'reason': 'camera control requested but no supported camera backend/devices are available'
}
return caps
async def connect_and_run(self):
"""Main loop: connect to gateway and process commands."""
url = f"{self.config['gateway_url']}?node_name={self.config['node_name']}&token={self.config['token']}"
_log_connect(url)
ssl_context = None
if url.startswith('wss://'):
cert_path = self.config.get('gateway_cert_path')
if cert_path:
ssl_context = ssl.create_default_context(cafile=str(Path(cert_path).expanduser()))
else:
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
_log_tls_disabled()
while True:
try:
async with websockets.connect(url, ping_interval=20, ping_timeout=10, ssl=ssl_context) as ws:
_log_connected()
# Send registration frame expected by the gateway
_log_registering(self.config['node_name'])
await ws.send(json.dumps({
"type": "register",
"node_name": self.config['node_name'],
"version": "1.0",
"tools": self._get_available_tools(),
"capabilities": self.capabilities
}))
_log_waiting()
heartbeat_task = asyncio.create_task(self._heartbeat_loop(ws))
disconnect_reason = None
try:
async for raw in ws:
msg = json.loads(raw)
await self._handle_message(ws, msg)
except Exception as e:
disconnect_reason = e
raise
finally:
heartbeat_task.cancel()
try:
await heartbeat_task
except asyncio.CancelledError:
pass
_log_disconnected(disconnect_reason)
except Exception as e:
_log_connection_error(e)
_log_reconnect(self.config['reconnect_interval'], e)
await asyncio.sleep(self.config['reconnect_interval'])
def _get_available_tools(self) -> list:
"""Return list of capability strings for gateway registration."""
tools = ['exec']
if self.browser is not None:
tools.append('browser_control')
if self.computer is not None:
tools.append('computer_control')
if self.config.get('enable_desktop_observe') and self.computer is not None:
tools.append('desktop_observe')
if self.config.get('enable_audio_control') and self.audio is not None:
tools.append('audio_control')
if self.config.get('enable_camera_control') and self.camera is not None:
tools.append('camera_control')
return tools
async def _handle_message(self, ws, msg: Dict[str, Any]):
req_type = msg.get('type')
if req_type == 'exec':
cmd_id = msg['id']
command = msg['command']
await self._handle_exec(ws, cmd_id, command, msg.get('approved', False))
elif req_type == 'computer_control':
action = msg['action']
params = msg.get('params', {})
await self._handle_cc(ws, msg.get('id'), action, params)
elif req_type == 'desktop_observe':
action = msg['action']
params = msg.get('params', {})
await self._handle_desktop_observe(ws, msg.get('id'), action, params)
elif req_type == 'browser_control':
command = msg.get('command')
params = msg.get('params', {})
await self._handle_browser_control(ws, msg.get('id'), command, params)
elif req_type == 'audio_control':
action = msg['action']
params = msg.get('params', {})
await self._handle_audio_control(ws, msg.get('id'), action, params)
elif req_type == 'camera_control':
action = msg['action']
params = msg.get('params', {})
await self._handle_camera_control(ws, msg.get('id'), action, params)
elif req_type == 'register_ack':
_log_registration_ack()
_log_registered(self.config['node_name'])
elif req_type == 'heartbeat_ack':
_log_heartbeat_ack()
return
else:
_log_unknown_message(req_type)
async def _heartbeat_loop(self, ws):
"""Send periodic heartbeats so the gateway can track liveness."""
interval = max(5, int(self.config.get('heartbeat_interval', 30)))
try:
while True:
await asyncio.sleep(interval)
await ws.send(json.dumps({
'type': 'heartbeat',
'node_name': self.config['node_name'],
'timestamp': time.time(),
}))
except asyncio.CancelledError:
raise
except Exception as e:
_log_connection_error(f"heartbeat loop stopped: {e}")
async def _send_json(self, ws, payload: Dict[str, Any]):
await ws.send(json.dumps(payload))
async def _handle_exec(self, ws, cmd_id: str, command: str, approved: bool = False):
"""Execute a shell command via the configured executor.
Uses the gateway's current exec protocol:
- optional streamed chunks via ``exec_output``
- terminal result via ``exec_complete``
"""
_log_exec_received(command)
try:
result = self.executor.execute(command, approved=approved)
stdout = result.get('stdout', '') or ''
stderr = result.get('stderr', '') or ''
if stdout:
await self._send_json(ws, {
'type': 'exec_output',
'id': cmd_id,
'stream': 'stdout',
'data': stdout,
})
if stderr:
await self._send_json(ws, {
'type': 'exec_output',
'id': cmd_id,
'stream': 'stderr',
'data': stderr,
})
exit_code = result.get('exit_code', -1)
error = result.get('error')
if error:
_log_exec_failed(command, error, exit_code)
else:
_log_exec_completed(command, exit_code)
await self._send_json(ws, {
'type': 'exec_complete',
'id': cmd_id,
'exit_code': exit_code,
'error': error,
})
except Exception as e:
_log_exec_failed(command, str(e), -1)
await self._send_json(ws, {
'type': 'exec_complete',
'id': cmd_id,
'exit_code': -1,
'error': str(e),
})
async def _handle_cc(self, ws, cmd_id: str, action: str, params: Dict[str, Any]):
_log_cc_received(action)
if self.computer is None:
result = {
'success': False,
'error': 'computer_control not available on this node',
}
else:
try:
if action == 'screenshot':
result = self.computer.screenshot(params.get('output_path'))
elif action == 'mouse_move':
result = self.computer.mouse_move(int(params['x']), int(params['y']))
elif action == 'mouse_click':
result = self.computer.mouse_click(int(params.get('button', 1)))
elif action == 'mouse_position':
result = self.computer.mouse_position()
elif action == 'type_text':
result = self.computer.type_text(params['text'])
elif action == 'key_press':
result = self.computer.key_press(params['key'])
elif action == 'get_active_window':
result = self.computer.get_active_window()
else:
result = {'success': False, 'error': f'Unknown computer_control action: {action}'}
except Exception as e:
result = {'success': False, 'error': str(e)}
_log_tool_completed('computer', action, bool(result.get('success')), result.get('error'))
payload = {'type': 'computer_control_result', 'id': cmd_id, 'action': action}
payload.update(result)
await self._send_json(ws, payload)
async def _handle_desktop_observe(self, ws, cmd_id: str, action: str, params: Dict[str, Any]):
logger.info(f"observe ▶ {_preview_command(action)}")
if self.computer is None:
result = {
'success': False,
'error': 'desktop_observe requires computer_control support on this node',
}
else:
try:
if action in ('active_window', 'get_active_window'):
result = self.computer.get_active_window()
elif action == 'mouse_position':
result = self.computer.mouse_position()
elif action == 'screenshot':
result = self.computer.screenshot(params.get('output_path'))
else:
result = {'success': False, 'error': f'Unknown desktop_observe action: {action}'}
except Exception as e:
result = {'success': False, 'error': str(e)}
_log_tool_completed('observe', action, bool(result.get('success')), result.get('error'))
payload = {'type': 'desktop_observe_result', 'id': cmd_id, 'action': action}
payload.update(result)
await self._send_json(ws, payload)
async def _handle_browser_control(self, ws, cmd_id: str, command: str, params: Dict[str, Any]):
if self.browser is None:
await self._send_json(ws, {
'type': 'browser_control_result',
'id': cmd_id,
'command': command,
'success': False,
'error': 'browser_control not available on this node',
})
return
_log_browser_received(command)
try:
if hasattr(self.browser, 'execute'):
result = self.browser.execute(command, params)
elif hasattr(self.browser, 'run'):
result = self.browser.run(command, params)
else:
result = {'success': False, 'error': 'BrowserController has no execute/run entrypoint'}
except Exception as e:
result = {'success': False, 'error': str(e)}
_log_tool_completed('browser', command, bool(result.get('success')), result.get('error'))
payload = {'type': 'browser_control_result', 'id': cmd_id, 'command': command}
payload.update(result)
await self._send_json(ws, payload)
async def _handle_audio_control(self, ws, cmd_id: str, action: str, params: Dict[str, Any]):
_log_audio_received(action)
if self.audio is None:
await self._send_json(ws, {
'type': 'audio_control_result',
'id': cmd_id,
'action': action,
'success': False,
'error': 'audio_control not available on this node',
})
return
try:
if action == 'list_audio_devices':
result = self.audio.list_audio_devices()
elif action == 'get_audio_status':
result = self.audio.get_audio_status()
elif action == 'capture_output':
result = self.audio.capture_output(params)
elif action == 'capture_input':
result = self.audio.capture_input(params)
elif action == 'play_audio':
result = self.audio.play_audio(params)
else:
result = {'success': False, 'error': f'Unknown audio_control action: {action}'}
except Exception as e:
result = {'success': False, 'error': str(e)}
_log_tool_completed('audio', action, bool(result.get('success')), result.get('error'))
payload = {'type': 'audio_control_result', 'id': cmd_id, 'action': action}
payload.update(result)
await self._send_json(ws, payload)
async def _handle_camera_control(self, ws, cmd_id: str, action: str, params: Dict[str, Any]):
_log_camera_received(action)
if self.camera is None:
await self._send_json(ws, {
'type': 'camera_control_result',
'id': cmd_id,
'action': action,
'success': False,
'error': 'camera_control not available on this node',
})
return
try:
if action == 'list_cameras':
result = self.camera.list_cameras()
elif action == 'get_camera_status':
result = self.camera.get_camera_status()
elif action == 'capture_frame':
result = self.camera.capture_frame(params)
elif action == 'capture_video':
result = self.camera.capture_video(params)
else:
result = {'success': False, 'error': f'Unknown camera_control action: {action}'}
except Exception as e:
result = {'success': False, 'error': str(e)}
_log_tool_completed('camera', action, bool(result.get('success')), result.get('error'))
payload = {'type': 'camera_control_result', 'id': cmd_id, 'action': action}
payload.update(result)
await self._send_json(ws, payload)
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')
args = parser.parse_args()
if args.debug:
logging.getLogger().setLevel(logging.DEBUG)
# Load config to check token
config = NodeAgent(args.config)._load_config(args.config)
if config['token'] == DEFAULT_GATEWAY_TOKEN or config['token'] == 'GATEWAY_TOKEN_MUST_BE_PROVIDED':
logger.error("ERROR: Token not set in config. Edit ~/.config/hermes-node/config.json")
sys.exit(1)
agent = NodeAgent(args.config)
_log_start(config['node_name'], agent._get_available_tools())
try:
asyncio.run(agent.connect_and_run())
except KeyboardInterrupt:
logger.info("Shutting down")
if __name__ == '__main__':
main()
# Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyleft: this program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# Hermes Node Protocol
# Copyright (c) 2026 Stefy (nextime) Lanza <stefy@nexlab.net>
# All rights reserved.
#
# This software is released under the MIT License with a copyleft clause.
# See the LICENSE file for full terms.
# 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
# Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyleft: this program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# Hermes Node Protocol
# Copyright (c) 2026 Stefy (nextime) Lanza <stefy@nexlab.net>
# All rights reserved.
#
# This software is released under the MIT License with a copyleft clause.
# See the LICENSE file for full terms.
# 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 (Linux) ==="
echo ""
# Check if running as root (for service setup)
if [ "$EUID" -eq 0 ]; then
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."
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."
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 only if missing
# On modern Debian/Ubuntu with PEP 668, system pip may be externally managed.
echo "[1/5] Installing Python dependencies..."
if python3 -c "import websockets" 2>/dev/null; then
echo "✓ websockets library already installed"
else
PIP_OK=false
if command -v pip3 >/dev/null 2>&1; then
if pip3 install --quiet --user websockets 2>/dev/null; then
PIP_OK=true
fi
fi
if [ "$PIP_OK" != true ] && command -v pip >/dev/null 2>&1; then
if pip install --quiet --user websockets 2>/dev/null; then
PIP_OK=true
fi
fi
if [ "$PIP_OK" != true ]; then
echo "❌ Failed to install websockets in user site-packages."
echo " Try one of:"
echo " - apt install python3-websockets"
echo " - python3 -m venv ~/hermes-node-venv && ~/hermes-node-venv/bin/pip install websockets"
exit 1
fi
echo "✓ websockets library installed"
fi
# 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 "[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 or update config interactively, using existing values as defaults
echo "[3/5] Configuring node..."
EXISTING_GATEWAY_URL="wss://YOUR-GATEWAY-HOST:8765"
EXISTING_NODE_NAME="$(hostname)"
EXISTING_TOKEN="$(python3 -c "import secrets; print(secrets.token_hex(16))")"
EXISTING_ENABLE_BROWSER="false"
EXISTING_ENABLE_COMPUTER_CONTROL="false"
EXISTING_ENABLE_DESKTOP_OBSERVE="false"
EXISTING_ENABLE_AUDIO_CONTROL="false"
if [ -f "$CONFIG_DIR/config.json" ]; then
EXISTING_GATEWAY_URL=$(python3 - <<PY
import json
from pathlib import Path
p = Path(r'''$CONFIG_DIR/config.json''')
data = json.loads(p.read_text())
print(data.get('gateway_url', 'wss://YOUR-GATEWAY-HOST:8765'))
PY
)
EXISTING_NODE_NAME=$(python3 - <<PY
import json
from pathlib import Path
p = Path(r'''$CONFIG_DIR/config.json''')
data = json.loads(p.read_text())
print(data.get('node_name', '$(hostname)'))
PY
)
EXISTING_TOKEN=$(python3 - <<PY
import json
from pathlib import Path
p = Path(r'''$CONFIG_DIR/config.json''')
data = json.loads(p.read_text())
print(data.get('token', ''))
PY
)
EXISTING_ENABLE_BROWSER=$(python3 - <<PY
import json
from pathlib import Path
p = Path(r'''$CONFIG_DIR/config.json''')
data = json.loads(p.read_text())
print(str(bool(data.get('enable_browser', False))).lower())
PY
)
EXISTING_ENABLE_COMPUTER_CONTROL=$(python3 - <<PY
import json
from pathlib import Path
p = Path(r'''$CONFIG_DIR/config.json''')
data = json.loads(p.read_text())
print(str(bool(data.get('enable_computer_control', False))).lower())
PY
)
EXISTING_ENABLE_DESKTOP_OBSERVE=$(python3 - <<PY
import json
from pathlib import Path
p = Path(r'''$CONFIG_DIR/config.json''')
data = json.loads(p.read_text())
print(str(bool(data.get('enable_desktop_observe', False))).lower())
PY
)
EXISTING_ENABLE_AUDIO_CONTROL=$(python3 - <<PY
import json
from pathlib import Path
p = Path(r'''$CONFIG_DIR/config.json''')
data = json.loads(p.read_text())
print(str(bool(data.get('enable_audio_control', False))).lower())
PY
)
EXISTING_ENABLE_CAMERA_CONTROL=$(python3 - <<PY
import json
from pathlib import Path
p = Path(r'''$CONFIG_DIR/config.json''')
data = json.loads(p.read_text())
print(str(bool(data.get('enable_camera_control', False))).lower())
PY
)
echo " Existing config found: $CONFIG_DIR/config.json"
fi
read -r -p "Gateway URL [$EXISTING_GATEWAY_URL]: " GATEWAY_URL
GATEWAY_URL=${GATEWAY_URL:-$EXISTING_GATEWAY_URL}
read -r -p "Node name [$EXISTING_NODE_NAME]: " NODE_NAME
NODE_NAME=${NODE_NAME:-$EXISTING_NODE_NAME}
read -r -p "Token [$EXISTING_TOKEN]: " TOKEN
TOKEN=${TOKEN:-$EXISTING_TOKEN}
DEFAULT_BROWSER_CHOICE="n"
[ "$EXISTING_ENABLE_BROWSER" = "true" ] && DEFAULT_BROWSER_CHOICE="y"
read -r -p "Enable browser_control? (y/N) [$DEFAULT_BROWSER_CHOICE]: " ENABLE_BROWSER_INPUT
ENABLE_BROWSER_INPUT=${ENABLE_BROWSER_INPUT:-$DEFAULT_BROWSER_CHOICE}
case "$ENABLE_BROWSER_INPUT" in
y|Y|yes|YES) ENABLE_BROWSER=true ;;
*) ENABLE_BROWSER=false ;;
esac
DEFAULT_COMPUTER_CHOICE="n"
[ "$EXISTING_ENABLE_COMPUTER_CONTROL" = "true" ] && DEFAULT_COMPUTER_CHOICE="y"
read -r -p "Enable computer_control? (y/N) [$DEFAULT_COMPUTER_CHOICE]: " ENABLE_COMPUTER_INPUT
ENABLE_COMPUTER_INPUT=${ENABLE_COMPUTER_INPUT:-$DEFAULT_COMPUTER_CHOICE}
case "$ENABLE_COMPUTER_INPUT" in
y|Y|yes|YES) ENABLE_COMPUTER_CONTROL=true ;;
*) ENABLE_COMPUTER_CONTROL=false ;;
esac
DEFAULT_DESKTOP_CHOICE="n"
[ "$EXISTING_ENABLE_DESKTOP_OBSERVE" = "true" ] && DEFAULT_DESKTOP_CHOICE="y"
read -r -p "Enable desktop_observe? (y/N) [$DEFAULT_DESKTOP_CHOICE]: " ENABLE_DESKTOP_INPUT
ENABLE_DESKTOP_INPUT=${ENABLE_DESKTOP_INPUT:-$DEFAULT_DESKTOP_CHOICE}
case "$ENABLE_DESKTOP_INPUT" in
y|Y|yes|YES) ENABLE_DESKTOP_OBSERVE=true ;;
*) ENABLE_DESKTOP_OBSERVE=false ;;
esac
DEFAULT_AUDIO_CHOICE="n"
[ "$EXISTING_ENABLE_AUDIO_CONTROL" = "true" ] && DEFAULT_AUDIO_CHOICE="y"
read -r -p "Enable audio_control? (y/N) [$DEFAULT_AUDIO_CHOICE]: " ENABLE_AUDIO_INPUT
ENABLE_AUDIO_INPUT=${ENABLE_AUDIO_INPUT:-$DEFAULT_AUDIO_CHOICE}
case "$ENABLE_AUDIO_INPUT" in
y|Y|yes|YES) ENABLE_AUDIO_CONTROL=true ;;
*) ENABLE_AUDIO_CONTROL=false ;;
esac
DEFAULT_CAMERA_CHOICE="n"
[ "$EXISTING_ENABLE_CAMERA_CONTROL" = "true" ] && DEFAULT_CAMERA_CHOICE="y"
read -r -p "Enable camera_control? (y/N) [$DEFAULT_CAMERA_CHOICE]: " ENABLE_CAMERA_INPUT
ENABLE_CAMERA_INPUT=${ENABLE_CAMERA_INPUT:-$DEFAULT_CAMERA_CHOICE}
case "$ENABLE_CAMERA_INPUT" in
y|Y|yes|YES) ENABLE_CAMERA_CONTROL=true ;;
*) ENABLE_CAMERA_CONTROL=false ;;
esac
CAPABILITIES='["exec"]'
if [ "$ENABLE_BROWSER" = true ]; then
CAPABILITIES=$(python3 - <<'PY' "$CAPABILITIES"
import json, sys
caps = json.loads(sys.argv[1])
if 'browser_control' not in caps:
caps.append('browser_control')
print(json.dumps(caps))
PY
)
fi
if [ "$ENABLE_COMPUTER_CONTROL" = true ]; then
CAPABILITIES=$(python3 - <<'PY' "$CAPABILITIES"
import json, sys
caps = json.loads(sys.argv[1])
if 'computer_control' not in caps:
caps.append('computer_control')
print(json.dumps(caps))
PY
)
fi
if [ "$ENABLE_DESKTOP_OBSERVE" = true ]; then
CAPABILITIES=$(python3 - <<'PY' "$CAPABILITIES"
import json, sys
caps = json.loads(sys.argv[1])
if 'desktop_observe' not in caps:
caps.append('desktop_observe')
print(json.dumps(caps))
PY
)
fi
if [ "$ENABLE_AUDIO_CONTROL" = true ]; then
CAPABILITIES=$(python3 - <<'PY' "$CAPABILITIES"
import json, sys
caps = json.loads(sys.argv[1])
if 'audio_control' not in caps:
caps.append('audio_control')
print(json.dumps(caps))
PY
)
fi
if [ "$ENABLE_CAMERA_CONTROL" = true ]; then
CAPABILITIES=$(python3 - <<'PY' "$CAPABILITIES"
import json, sys
caps = json.loads(sys.argv[1])
if 'camera_control' not in caps:
caps.append('camera_control')
print(json.dumps(caps))
PY
)
fi
cat > "$CONFIG_DIR/config.json" << EOF
{
"gateway_url": "$GATEWAY_URL",
"node_name": "$NODE_NAME",
"token": "$TOKEN",
"reconnect_interval": 5,
"heartbeat_interval": 30,
"capabilities": $CAPABILITIES,
"enable_browser": $ENABLE_BROWSER,
"enable_computer_control": $ENABLE_COMPUTER_CONTROL,
"enable_desktop_observe": $ENABLE_DESKTOP_OBSERVE,
"enable_audio_control": $ENABLE_AUDIO_CONTROL,
"enable_camera_control": $ENABLE_CAMERA_CONTROL,
"permissions": {
"deny": ["sudo", "su", "doas", "dd if=", "mkfs", "fdisk", "wipe"],
"ask": ["rm -rf", "dd if=", "> /dev/", "chmod", "chown", "mv /", ":/usr/", ":/etc/", ":/bin/", ":/sbin/"],
"allow": []
}
}
EOF
echo "[4/5] Wrote config: $CONFIG_DIR/config.json"
# 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 "=== Installation Complete ==="
echo ""
echo "Next steps:"
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 Protocol
# Copyright (c) 2026 Stefy (nextime) Lanza <stefy@nexlab.net>
# All rights reserved.
#
# This software is released under the MIT License with a copyleft clause.
# See the LICENSE file for full terms.
websockets>=16.0
playwright>=1.59.0
<!-- Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net> -->
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!-- Copyleft: GNU GPLv3 or later applies to this file. -->
# Hermes Node Protocol
# Copyright (c) 2026 Stefy (nextime) Lanza <stefy@nexlab.net>
# All rights reserved.
#
# This software is released under the MIT License with a copyleft clause.
# See the LICENSE file for full terms.
# 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
# Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyleft: this program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# Hermes Node Protocol
# Copyright (c) 2026 Stefy (nextime) Lanza <stefy@nexlab.net>
# All rights reserved.
#
# This software is released under the MIT License with a copyleft clause.
# See the LICENSE file for full terms.
"""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()
# Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyleft: this program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# Hermes Node Protocol
# Copyright (c) 2026 Stefy (nextime) Lanza <stefy@nexlab.net>
# All rights reserved.
#
# This software is released under the MIT License with a copyleft clause.
# See the LICENSE file for full terms.
# 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;
......@@ -136,10 +136,20 @@ class ConfigEditor(wx.Frame):
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)
# Windows capabilities
caps_box = wx.StaticBoxSizer(wx.StaticBox(self.panel, label="Capabilities"), wx.VERTICAL)
caps_grid = wx.FlexGridSizer(3, 2, 8, 12)
self.enable_browser = wx.CheckBox(self.panel, label="browser_control")
self.enable_computer_control = wx.CheckBox(self.panel, label="computer_control")
self.enable_desktop_observe = wx.CheckBox(self.panel, label="desktop_observe")
self.enable_audio_control = wx.CheckBox(self.panel, label="audio_control")
self.enable_camera_control = wx.CheckBox(self.panel, label="camera_control")
caps_grid.Add(self.enable_browser, 0)
caps_grid.Add(self.enable_computer_control, 0)
caps_grid.Add(self.enable_desktop_observe, 0)
caps_grid.Add(self.enable_audio_control, 0)
caps_grid.Add(self.enable_camera_control, 0)
caps_box.Add(caps_grid, 0, wx.ALL, 5)
# Reconnect interval
grid.Add(wx.StaticText(self.panel, label="Reconnect (s):"), 0, wx.ALIGN_RIGHT)
......@@ -151,22 +161,9 @@ class ConfigEditor(wx.Frame):
self.heartbeat = wx.TextCtrl(self.panel, value="30")
grid.Add(self.heartbeat, 1, wx.EXPAND)
# Capabilities
cap_sizer = wx.BoxSizer(wx.HORIZONTAL)
self.cb_exec = wx.CheckBox(self.panel, label="exec")
self.cb_exec.SetValue(True)
self.cb_browser = wx.CheckBox(self.panel, label="browser_control")
self.cb_computer = wx.CheckBox(self.panel, label="computer_control")
label_txt = wx.StaticText(self.panel, label="Capabilities:")
cap_sizer.Add(label_txt, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 10)
cap_sizer.Add(self.cb_exec, 0, wx.RIGHT, 10)
cap_sizer.Add(self.cb_browser, 0, wx.RIGHT, 10)
cap_sizer.Add(self.cb_computer, 0)
self.sizer.Add(cap_sizer, 0, wx.ALL | wx.EXPAND, 15)
grid.AddGrowableCol(1, 1)
self.sizer.Add(grid, 0, wx.ALL | wx.EXPAND, 15)
self.sizer.Add(caps_box, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.EXPAND, 15)
# Buttons
btn_sizer = wx.BoxSizer(wx.HORIZONTAL)
......@@ -188,30 +185,26 @@ class ConfigEditor(wx.Frame):
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)))
# Capabilities checkboxes
caps = cfg.get('capabilities', ['exec'])
self.cb_exec.SetValue('exec' in caps)
self.cb_browser.SetValue('browser_control' in caps)
self.cb_computer.SetValue('computer_control' in caps)
# Load capabilities
caps = cfg.get('capabilities', ['exec'])
self.cb_exec.SetValue('exec' in caps)
self.cb_browser.SetValue('browser_control' in caps)
self.cb_computer.SetValue('computer_control' in caps)
def _get_capabilities(self) -> list:
"""Collect capabilities from checkboxes."""
caps = []
if self.cb_exec.GetValue():
caps.append('exec')
if self.cb_browser.GetValue():
self.enable_browser.SetValue(bool(cfg.get('enable_browser', False)))
self.enable_computer_control.SetValue(bool(cfg.get('enable_computer_control', False)))
self.enable_desktop_observe.SetValue(bool(cfg.get('enable_desktop_observe', False)))
self.enable_audio_control.SetValue(bool(cfg.get('enable_audio_control', False)))
self.enable_camera_control.SetValue(bool(cfg.get('enable_camera_control', False)))
def _capabilities_from_flags(self, cfg: dict) -> list:
caps = ['exec']
if cfg.get('enable_browser'):
caps.append('browser_control')
if self.cb_computer.GetValue():
if cfg.get('enable_computer_control'):
caps.append('computer_control')
if cfg.get('enable_desktop_observe'):
caps.append('desktop_observe')
if cfg.get('enable_audio_control'):
caps.append('audio_control')
if cfg.get('enable_camera_control'):
caps.append('camera_control')
return caps
def on_save(self, event):
......@@ -219,11 +212,15 @@ class ConfigEditor(wx.Frame):
'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()),
'capabilities': self._get_capabilities(),
'enable_browser': self.enable_browser.GetValue(),
'enable_computer_control': self.enable_computer_control.GetValue(),
'enable_desktop_observe': self.enable_desktop_observe.GetValue(),
'enable_audio_control': self.enable_audio_control.GetValue(),
'enable_camera_control': self.enable_camera_control.GetValue(),
}
cfg['capabilities'] = self._capabilities_from_flags(cfg)
if not cfg['gateway_url'] or not cfg['token']:
wx.MessageBox("Gateway URL and Token are required", "Error", wx.OK | wx.ICON_ERROR)
return
......@@ -303,7 +300,7 @@ class StatusDialog(wx.Frame):
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"Capabilities : {', '.join(cfg.get('capabilities', ['exec']))}",
f"Config File : {str(CONFIG_FILE)}",
f"Log File : {str(LOG_FILE)}",
f"Agent Dir : {str(AGENT_DIR)}",
......
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