Commit 20be10fd authored by Lisa (Hermes AI)'s avatar Lisa (Hermes AI)

fix: validate node_name parameter before execution

- Check if node_name is provided before attempting execution
- Show available nodes in error message when node not found
- Prevents ValueError with 'None' node name
parent c958bccc
# 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 Gateway Plugin
==========================
Integrated node management plugin for Hermes Agent.
Runs an embedded WebSocket server (WSS) within the Hermes process to accept
reverse connections from remote nodes. No separate gateway process needed.
Architecture:
- Plugin starts a background thread running a WebSocket server
- Remote nodes connect via WSS with token authentication
- Commands are routed through the WebSocket to nodes
- Responses stream back in real-time
- Preserves existing sexec permission system
Tools provided:
- node_list: List all connected nodes
- node_status: Get status of a specific node
- node_exec: Execute command on a remote node
"""
from __future__ import annotations
import asyncio
import json
import logging
import ssl as ssl_lib
import threading
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Any
logger = logging.getLogger(__name__)
# Check for required dependencies
try:
import websockets
except ImportError:
logger.error("websockets library not found. Install with: pip install websockets")
websockets = None
# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------
class NodeInfo:
"""Information about a connected node"""
__slots__ = ('name', 'socket', 'connected_at', 'last_seen', 'version', 'capabilities', 'sexec_path')
__slots__ = ('name', 'socket', 'connected_at', 'last_seen', 'version', 'capabilities', 'tools')
def __init__(self, name, socket, connected_at, last_seen, version, capabilities, sexec_path):
def __init__(self, name, socket, connected_at, last_seen, version, capabilities, tools):
self.name = name
self.socket = socket
self.connected_at = connected_at
self.last_seen = last_seen
self.version = version
self.capabilities = capabilities
self.sexec_path = sexec_path
self.tools = tools
class CommandExecution:
"""Track command execution state"""
__slots__ = ('id', 'node_name', 'command', 'status', 'stdout', 'stderr',
'exit_code', 'started_at', 'completed_at', 'error', 'approved',
'browser_result')
def __init__(self, id, node_name, command, status, approved=False,
stdout="", stderr="", exit_code=None, started_at=0,
completed_at=0, error=None):
self.id = id
self.node_name = node_name
self.command = command
self.status = status
self.approved = approved
self.stdout = stdout
self.stderr = stderr
self.exit_code = exit_code
self.started_at = started_at
self.completed_at = completed_at
self.error = error
# ---------------------------------------------------------------------------
# Node Gateway (embedded WebSocket server)
# ---------------------------------------------------------------------------
class NodeGateway:
"""
Embedded WebSocket server for node connections.
Runs in a background thread within the Hermes process.
"""
def __init__(self, config: Dict[str, Any]):
self.config = config
self.bind_address = config.get('bind_address', '0.0.0.0')
self.websocket_port = config.get('websocket_port', 8765)
self.use_tls = config.get('use_tls', True)
self.cert_dir = Path(config.get('cert_dir', '~/.config/hermes-node-gateway/certs')).expanduser()
self.tokens = config.get('tokens', {})
# In-memory state (thread-safe)
self.nodes: Dict[str, NodeInfo] = {}
self.commands: Dict[str, CommandExecution] = {}
self.command_waiters: Dict[str, asyncio.Future] = {}
self._nodes_lock = threading.Lock()
self._commands_lock = threading.Lock()
# Background thread
self._websocket_thread: Optional[threading.Thread] = None
self._websocket_server = None
self._running = False
self._loop = None
# Permission system
self.permissions = self._load_permissions(
config.get('permissions_path', '~/.config/hermes-node-gateway/permissions.json')
)
def _load_permissions(self, path: str) -> Dict:
"""Load node permission configuration"""
try:
with open(Path(path).expanduser()) as f:
return json.load(f)
except FileNotFoundError:
logger.warning(f"Permissions file not found: {path}, using defaults")
return {'allow': [], 'ask': [], 'deny': []}
except Exception as e:
logger.error(f"Error loading permissions: {e}")
return {'allow': [], 'ask': [], 'deny': []}
def start(self):
"""Start the WebSocket server in a background thread"""
if self._running:
logger.warning("Node gateway already running")
return
if websockets is None:
logger.error("Cannot start node gateway: websockets library not installed")
return
self._running = True
self._websocket_thread = threading.Thread(
target=self._run_websocket_server,
name="Hermes-Node-Gateway",
daemon=True
)
self._websocket_thread.start()
# Wait for server to start
time.sleep(0.5)
logger.info("Node gateway started in background thread")
def stop(self):
"""Stop the WebSocket server"""
self._running = False
if self._websocket_server and self._loop:
try:
asyncio.run_coroutine_threadsafe(
self._websocket_server.wait_closed(),
self._loop
)
except Exception as e:
logger.error(f"Error stopping websocket server: {e}")
if self._websocket_thread:
self._websocket_thread.join(timeout=5)
logger.info("Node gateway stopped")
def _run_websocket_server(self):
"""Run WebSocket server in background thread"""
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
# Setup TLS if enabled
ssl_context = None
if self.use_tls:
try:
cert_file = str(self.cert_dir / 'gateway.crt')
key_file = str(self.cert_dir / 'gateway.key')
if not Path(cert_file).exists() or not Path(key_file).exists():
logger.warning(f"TLS certificates not found at {self.cert_dir}, running without TLS")
else:
import ssl as ssl_lib
ssl_context = ssl_lib.SSLContext(ssl_lib.PROTOCOL_TLS_SERVER)
ssl_context.load_cert_chain(cert_file, key_file)
logger.info(f"TLS enabled for node gateway")
except Exception as e:
logger.error(f"Failed to setup TLS: {e}")
# Create server coroutine and start it
async def start_server():
return await websockets.serve(
self._handle_node_connection,
self.bind_address,
self.websocket_port,
ssl=ssl_context,
ping_interval=20,
ping_timeout=10,
)
self._websocket_server = self._loop.run_until_complete(start_server())
protocol = "wss" if ssl_context else "ws"
logger.info(
f"Node gateway listening on {protocol}://{self.bind_address}:{self.websocket_port}"
)
# Run forever
try:
self._loop.run_forever()
except KeyboardInterrupt:
logger.info("Node gateway interrupted")
finally:
self._loop.close()
async def _handle_node_connection(self, socket, path):
async def _handle_node_connection(self, websocket):
"""Handle incoming node connection"""
node_name = None
try:
# Extract token from query string
path = websocket.request.path
query = path.split('?', 1)[1] if '?' in path else ''
params = dict(pair.split('=') for pair in query.split('&') if '=' in pair)
token = params.get('token')
socket = websocket
if not token:
logger.warning("Connection rejected: no token")
await socket.close(1008, "Authentication required")
return
# Wait for registration
try:
reg_msg = await asyncio.wait_for(socket.recv(), timeout=10)
registration = json.loads(reg_msg)
except (asyncio.TimeoutError, json.JSONDecodeError) as e:
logger.warning(f"Connection rejected: {e}")
await socket.close(1008, "Registration timeout or invalid")
return
if registration.get('type') != 'register':
await socket.close(1008, "Expected registration")
return
node_name = registration.get('node_name')
if not node_name:
await socket.close(1008, "Missing node_name")
return
# Validate token
expected_token = self.tokens.get(node_name)
if token != expected_token:
logger.warning(f"Invalid token for node '{node_name}'")
await socket.close(1008, "Invalid token")
return
# Register node
node_info = NodeInfo(
name=node_name,
socket=socket,
connected_at=time.time(),
last_seen=time.time(),
version=registration.get('version', 'unknown'),
capabilities=registration.get('capabilities', []),
sexec_path=registration.get('sexec_path', '/usr/local/bin/sexec.sh')
tools=registration.get('tools', ['exec'])
)
with self._nodes_lock:
self.nodes[node_name] = node_info
logger.info(f"Node '{node_name}' connected (version {node_info.version})")
# Send registration ack
await socket.send(json.dumps({
'type': 'register_ack',
'status': 'ok',
'gateway_version': '2.0'
}))
# Handle messages from this node
async for message in socket:
await self._handle_node_message(node_name, message)
except Exception as e:
# Connection closed
except websockets.exceptions.ConnectionClosed:
# Normal disconnection
if node_name:
logger.info(f"Node '{node_name}' disconnected")
except Exception as e:
# Unexpected error
logger.error(f"Error handling node connection: {e}")
finally:
if node_name:
with self._nodes_lock:
self.nodes.pop(node_name, None)
logger.info(f"Node '{node_name}' removed from registry")
async def _handle_node_message(self, node_name: str, message: str):
"""Handle message from a connected node"""
try:
msg = json.loads(message)
msg_type = msg.get('type')
if msg_type == 'exec_output':
cmd_id = msg.get('id')
with self._commands_lock:
if cmd_id in self.commands:
cmd = self.commands[cmd_id]
stream = msg.get('stream', '')
data = msg.get('data', '')
if stream == 'stdout':
cmd.stdout += data
elif stream == 'stderr':
cmd.stderr += data
elif msg_type == 'exec_complete':
cmd_id = msg.get('id')
with self._commands_lock:
if cmd_id in self.commands:
cmd = self.commands[cmd_id]
cmd.status = 'completed' if msg.get('exit_code') == 0 else 'failed'
cmd.exit_code = msg.get('exit_code')
cmd.completed_at = time.time()
cmd.error = msg.get('error')
# Notify waiter
if cmd_id in self.command_waiters:
self.command_waiters[cmd_id].set_result(cmd)
elif msg_type == 'heartbeat':
with self._nodes_lock:
if node_name in self.nodes:
self.nodes[node_name].last_seen = time.time()
# Send heartbeat ack
if node_name in self.nodes:
await self.nodes[node_name].socket.send(json.dumps({
'type': 'heartbeat_ack',
'timestamp': msg.get('timestamp')
}))
elif msg_type == 'browser_control_response':
await self._handle_browser_control_response(msg)
elif msg_type == 'cc_result':
await self._handle_cc_result(msg)
except Exception as e:
logger.error(f"Error handling message from {node_name}: {e}")
async def _handle_browser_control_response(self, msg: dict):
"""Handle browser control response from node"""
cmd_id = msg.get("id")
result_type = msg.get("result")
if cmd_id not in self.command_waiters:
logger.warning(f"Response for unknown browser command: {cmd_id}")
return
with self._commands_lock:
if cmd_id not in self.commands:
return
cmd = self.commands[cmd_id]
if result_type == "ok":
cmd.status = "completed"
cmd.exit_code = 0
else:
cmd.status = "failed"
cmd.exit_code = -1
cmd.error = msg.get("error")
cmd.completed_at = time.time()
# Store the full response for retrieval
cmd.browser_result = msg
logger.info(f"Browser command {cmd_id} completed: {result_type}")
# Wake up any waiters
if cmd_id in self.command_waiters:
self.command_waiters[cmd_id].set_result(cmd)
async def _handle_cc_result(self, msg: dict):
"""Handle computer_control response from node"""
cmd_id = msg.get("id")
if cmd_id not in self.command_waiters:
logger.warning(f"Response for unknown computer_control command: {cmd_id}")
return
with self._commands_lock:
if cmd_id not in self.commands:
return
cmd = self.commands[cmd_id]
success = msg.get("success", False)
cmd.status = "completed" if success else "failed"
cmd.exit_code = 0 if success else -1
cmd.error = msg.get("error")
cmd.completed_at = time.time()
# Store the full response under browser_result (reuse slot)
cmd.browser_result = msg
logger.info(f"computer_control command {cmd_id} completed: {cmd.status}")
if cmd_id in self.command_waiters:
self.command_waiters[cmd_id].set_result(cmd)
async def execute_browser_command(
self,
node_name: str,
command: Dict[str, Any],
timeout: int = 30
) -> Dict[str, Any]:
"""Execute a browser control command on a node (async)"""
# Check if node is connected
with self._nodes_lock:
if node_name not in self.nodes:
raise ValueError(f"Node '{node_name}' is not connected")
node = self.nodes[node_name]
# Check if node has browser control capability
if "browser_control" not in node.capabilities:
raise ValueError(f"Node '{node_name}' does not support browser control")
# Create command execution
cmd_id = f"browser-{uuid.uuid4().hex[:8]}"
cmd = CommandExecution(
id=cmd_id,
node_name=node_name,
command=[str(command)],
status='pending',
started_at=time.time()
)
cmd.browser_result = None
with self._commands_lock:
self.commands[cmd_id] = cmd
# Send browser control message to node
msg = {
"type": "browser_control",
"id": cmd_id,
**command
}
await node.socket.send(json.dumps(msg))
cmd.status = 'running'
logger.info(f"Sent browser command {cmd_id} to node '{node_name}': {command.get('command')}")
# Wait for completion
future = asyncio.Future()
self.command_waiters[cmd_id] = future
try:
result = await asyncio.wait_for(future, timeout=timeout)
return {
'id': result.id,
'status': result.status,
'error': result.error,
'duration_ms': int((result.completed_at - result.started_at) * 1000)
if result.started_at and result.completed_at else None,
**(result.browser_result or {})
}
except asyncio.TimeoutError:
cmd.status = 'failed'
cmd.error = 'Gateway timeout'
return {
'id': cmd_id,
'status': 'failed',
'error': 'Gateway timeout',
'exit_code': -1
}
finally:
self.command_waiters.pop(cmd_id, None)
def execute_browser_command_sync(
self,
node_name: str,
command: Dict[str, Any],
timeout: int = 30
) -> Dict[str, Any]:
"""Synchronous wrapper for execute_browser_command"""
if not self._loop:
raise RuntimeError("Node gateway not started")
future = asyncio.run_coroutine_threadsafe(
self.execute_browser_command(node_name, command, timeout),
self._loop
)
return future.result(timeout=timeout + 10)
def execute_computer_control_command_sync(
self,
node_name: str,
command: Dict[str, Any],
timeout: int = 30
) -> Dict[str, Any]:
"""Synchronous wrapper for execute_computer_control_command"""
if not self._loop:
raise RuntimeError("Node gateway not started")
future = asyncio.run_coroutine_threadsafe(
self.execute_computer_control_command(node_name, command, timeout),
self._loop
)
return future.result(timeout=timeout + 10)
async def execute_computer_control_command(
self,
node_name: str,
command: Dict[str, Any],
timeout: int = 30
) -> Dict[str, Any]:
"""Execute a computer control command on a node (async)"""
with self._nodes_lock:
if node_name not in self.nodes:
raise ValueError(f"Node '{node_name}' is not connected")
node = self.nodes[node_name]
if "computer_control" not in node.capabilities:
raise ValueError(f"Node '{node_name}' does not support computer_control")
cmd_id = f"cc-{uuid.uuid4().hex[:8]}"
cmd = CommandExecution(
id=cmd_id, node_name=node_name,
command=[json.dumps(command)], status='pending',
started_at=time.time()
)
cmd.browser_result = None
with self._commands_lock:
self.commands[cmd_id] = cmd
msg = {"type": "computer_control", "id": cmd_id, **command}
await node.socket.send(json.dumps(msg))
cmd.status = 'running'
logger.info(f"Sent computer_control command to node '{node_name}': {command.get('action')}")
future = asyncio.Future()
self.command_waiters[cmd_id] = future
try:
result = await asyncio.wait_for(future, timeout=timeout)
return {
'id': result.id, 'status': result.status,
'error': result.error,
'duration_ms': int((result.completed_at - result.started_at) * 1000)
if result.started_at and result.completed_at else None,
**(result.browser_result or {})
}
except asyncio.TimeoutError:
cmd.status = 'failed'
cmd.error = 'Gateway timeout'
return {'id': cmd_id, 'status': 'failed', 'error': 'Gateway timeout'}
finally:
self.command_waiters.pop(cmd_id, None)
# -------------------------------------------------------------------------
# Public API (thread-safe)
# -------------------------------------------------------------------------
def list_nodes(self) -> Dict[str, Any]:
"""List all connected nodes"""
with self._nodes_lock:
nodes_list = []
for name, node in self.nodes.items():
nodes_list.append({
'name': name,
'status': 'connected',
'connected_at': node.connected_at,
'last_seen': node.last_seen,
'uptime': int(time.time() - node.connected_at),
'version': node.version,
'tools': node.tools,
'capabilities': node.capabilities
})
return {'nodes': nodes_list}
def _get_node_tools(self, node) -> List[str]:
"""Get tool list for a node; fall back to inferring from capabilities for old nodes."""
if hasattr(node, 'tools') and node.tools:
return node.tools
# Backward compatibility: infer tools from capabilities
tools = ['exec']
caps = getattr(node, 'capabilities', {}) or {}
if isinstance(caps, dict) and caps.get('enable_browser'):
tools.append('browser_control')
if isinstance(caps, dict) and caps.get('enable_computer_control'):
tools.append('computer_control')
return tools
def get_node_status(self, node_name: str) -> Dict[str, Any]:
"""Get status of a specific node"""
with self._nodes_lock:
if node_name not in self.nodes:
raise ValueError(f"Node '{node_name}' not found")
node = self.nodes[node_name]
return {
'name': node_name,
'status': 'connected',
'connected_at': node.connected_at,
'last_seen': node.last_seen,
'uptime': int(time.time() - node.connected_at),
'version': node.version,
'capabilities': node.capabilities,
'sexec_path': node.sexec_path
'tools': self._get_node_tools(node),
'capabilities': node.capabilities
}
def execute_command_sync(
self,
node_name: str,
command: List[str],
timeout: int = 30,
approved: bool = False
) -> Dict[str, Any]:
"""Synchronous wrapper for execute_command"""
if not self._loop:
raise RuntimeError("Node gateway not started")
future = asyncio.run_coroutine_threadsafe(
self.execute_command(node_name, command, timeout, approved),
self._loop
)
return future.result(timeout=timeout + 10)
async def execute_command(
self,
node_name: str,
command: List[str],
timeout: int = 30,
approved: bool = False
) -> Dict[str, Any]:
"""Execute command on a node (async)"""
# Check if node is connected
with self._nodes_lock:
if node_name not in self.nodes:
raise ValueError(f"Node '{node_name}' is not connected")
node = self.nodes[node_name]
# Permission check
cmd_str = ' '.join(command)
# Deny list check
for pattern in self.permissions.get('deny', []):
if self._matches_pattern(cmd_str, pattern):
raise PermissionError(
f"Command denied: matches deny pattern '{pattern}'"
)
# Ask list - requires approval
if not approved:
for pattern in self.permissions.get('ask', []):
if self._matches_pattern(cmd_str, pattern):
raise PermissionError(
f"Command requires approval: matches pattern '{pattern}'"
)
# Check allow list (if configured)
allow_list = self.permissions.get('allow', [])
if allow_list:
allowed = any(
self._matches_pattern(cmd_str, pattern)
for pattern in allow_list
)
if not allowed:
raise PermissionError(f"Command not in allow list")
# Create command execution record
cmd_id = f"cmd-{uuid.uuid4().hex[:12]}"
cmd = CommandExecution(
id=cmd_id,
node_name=node_name,
command=command,
status='pending',
started_at=time.time()
)
with self._commands_lock:
self.commands[cmd_id] = cmd
# Send command to node
await node.socket.send(json.dumps({
'type': 'exec',
'id': cmd_id,
'command': command,
'timeout': timeout,
'approved': approved
}))
logger.info(f"Sent command {cmd_id} to node '{node_name}': {cmd_str}")
# Wait for completion
future = asyncio.Future()
self.command_waiters[cmd_id] = future
try:
result = await asyncio.wait_for(future, timeout=timeout + 5)
return {
'id': result.id,
'status': result.status,
'exit_code': result.exit_code,
'stdout': result.stdout,
'stderr': result.stderr,
'error': result.error,
'duration_ms': int((result.completed_at - result.started_at) * 1000)
if result.completed_at and result.started_at else None
}
except asyncio.TimeoutError:
cmd.status = 'failed'
cmd.error = 'Gateway timeout'
return {
'id': cmd_id,
'status': 'failed',
'error': 'Gateway timeout',
'exit_code': -1
}
finally:
self.command_waiters.pop(cmd_id, None)
def _matches_pattern(self, command: str, pattern: str) -> bool:
"""Check if command matches a pattern (supports wildcards)"""
import re
regex = pattern.replace('*', '.*')
return bool(re.search(regex, command))
def close(self):
"""Clean up resources"""
self.stop()
# ---------------------------------------------------------------------------
# Global gateway instance
# ---------------------------------------------------------------------------
_gateway: Optional[NodeGateway] = None
def _get_gateway() -> NodeGateway:
"""Get the singleton gateway instance"""
global _gateway
if _gateway is None:
raise RuntimeError("Node gateway plugin not initialized")
return _gateway
def _init_gateway(config: Dict[str, Any]) -> NodeGateway:
"""Initialize the gateway"""
global _gateway
if _gateway is None:
_gateway = NodeGateway(config)
_gateway.start()
return _gateway
def _shutdown_gateway():
"""Shutdown the gateway"""
global _gateway
if _gateway:
_gateway.close()
_gateway = None
# ---------------------------------------------------------------------------
# Tool schemas
# ---------------------------------------------------------------------------
NODE_LIST_SCHEMA = {
"type": "function",
"function": {
"name": "node_list",
"description": "List all connected remote nodes",
"parameters": {
"type": "object",
"properties": {},
},
},
}
NODE_STATUS_SCHEMA = {
"type": "function",
"function": {
"name": "node_status",
"description": "Get the status of a specific node",
"parameters": {
"type": "object",
"properties": {
"node_name": {
"type": "string",
"description": "Name of the node (e.g., 'sissy', 'zeiss', 'ganeti1')"
}
},
"required": ["node_name"],
},
},
}
NODE_EXEC_SCHEMA = {
"type": "function",
"function": {
"name": "node_exec",
"description": "Execute a command on a remote node via the node gateway. Commands are filtered through the sexec permission system (allow/ask/deny lists).",
"parameters": {
"type": "object",
"properties": {
"node_name": {
"type": "string",
"description": "Name of the target node (sissy, zeiss, spank, ganeti1, ganeti2)"
},
"command": {
"type": "array",
"items": {"type": "string"},
"description": "Command and its arguments as a list (e.g., ['df', '-h'])"
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds (default: 30)",
"default": 30
},
"approved": {
"type": "boolean",
"description": "Set to true ONLY after user explicitly approved this command",
"default": False
}
},
"required": ["node_name", "command"],
},
},
}
BROWSER_CONTROL_SCHEMA = {
"type": "function",
"function": {
"name": "browser_control",
"description": "Control a browser on a remote node via Playwright. Supports high-level actions (navigate, click, fill, screenshot), Playwright API, and CDP commands.",
"parameters": {
"type": "object",
"properties": {
"node_name": {
"type": "string",
"description": "Name of the target node with browser control capability"
},
"command": {
"type": "string",
"description": "Browser command: launch, navigate, click, fill, screenshot, evaluate, execute_script, cdp, playwright, or custom API call"
},
"layer": {
"type": "string",
"enum": ["high_level", "playwright", "cdp"],
"description": "API layer: high_level (simple actions), playwright (direct Playwright methods), cdp (Chrome DevTools Protocol)",
"default": "high_level"
},
"page_id": {
"type": "string",
"description": "Target page identifier (omit for launch/list_*)",
"default": "page_1"
},
"params": {
"type": "object",
"description": "Command parameters. For launch: {config, extension_paths}. For high-level: action-specific. For cdp/playwright: full param dict.",
"additionalProperties": True
},
"timeout": {
"type": "integer",
"description": "Command timeout in seconds",
"default": 30
},
"tools": {
"type": "array",
"description": "Tool tags to invoke on node (e.g., ['cdp', 'inject', 'extension']). Advanced filtering.",
"items": {"type": "string"}
}
},
"required": ["node_name", "command"],
},
},
}
COMPUTER_CONTROL_SCHEMA = {
"type": "function",
"function": {
"name": "computer_control",
"description": "Control desktop automation on a remote node via X11 tools (xdotool, import). Supports screenshot, mouse movement, keyboard input, and window management.",
"parameters": {
"type": "object",
"properties": {
"node_name": {
"type": "string",
"description": "Name of the target node with computer_control capability"
},
"action": {
"type": "string",
"enum": [
"screenshot", "mouse_move", "mouse_click", "mouse_position",
"type", "key", "active_window"
],
"description": "Desktop action to perform"
},
"params": {
"type": "object",
"description": "Action parameters (key varies by action)",
"additionalProperties": True
}
},
"required": ["node_name", "action"]
}
}
}
# ---------------------------------------------------------------------------
# Tool handlers
# ---------------------------------------------------------------------------
def tool_node_list() -> Dict[str, Any]:
def tool_node_list(params: Dict[str, Any] = None, **kwargs) -> Dict[str, Any]:
"""List all connected nodes"""
gw = _get_gateway()
return gw.list_nodes()
return json.dumps(gw.list_nodes())
def tool_node_status(node_name: str) -> Dict[str, Any]:
def tool_node_status(params: Dict[str, Any] = None, **kwargs) -> Dict[str, Any]:
"""Get status of a specific node"""
gw = _get_gateway()
return gw.get_node_status(node_name)
node_name = params.get('node_name')
return json.dumps(gw.get_node_status(node_name))
def tool_node_exec(
node_name: str,
command: List[str],
timeout: int = 30,
approved: bool = False
) -> Dict[str, Any]:
def tool_node_exec(params: Dict[str, Any] = None, **kwargs) -> Dict[str, Any]:
"""Execute command on a node"""
gw = _get_gateway()
return gw.execute_command_sync(node_name, command, timeout, approved)
def tool_browser_control(
node_name: str,
command: str,
layer: str = "high_level",
page_id: str = "page_1",
params: Dict[str, Any] = None,
timeout: int = 30
) -> Dict[str, Any]:
"""Execute browser control command on a node"""
gw = _get_gateway()
# Build the command dict
cmd_dict = {
"layer": layer,
"command": command,
"page_id": page_id,
"params": params or {}
}
return gw.execute_browser_command_sync(node_name, cmd_dict, timeout)
def tool_computer_control(
node_name: str,
action: str,
params: Dict[str, Any] = None,
timeout: int = 30
) -> Dict[str, Any]:
"""Execute computer control command on a node (screenshot, mouse, keyboard, window control)"""
gw = _get_gateway()
node_name = params.get('node_name')
command = params.get('command')
timeout = params.get('timeout', 30)
approved = params.get('approved', False)
if not node_name:
raise ValueError("node_name is required")
if not action:
raise ValueError("action is required")
raise ValueError("Missing required parameter: 'node_name'")
# Send command via execute_computer_control_command_sync
payload = {
"action": action,
"params": params or {}
}
with gw._nodes_lock:
if node_name not in gw.nodes:
available = list(gw.nodes.keys())
raise ValueError(f"Node '{node_name}' is not connected. Available nodes: {available}")
return gw.execute_computer_control_command_sync(node_name, payload, timeout)
return json.dumps(gw.execute_command_sync(node_name, command, timeout, approved))
def tool_browser_control(*args, **kwargs) -> Dict[str, Any]:
"""Execute browser control command on a node"""
gw = _get_gateway()
params = args[0] if args else kwargs
nodename = params.get('node_name') or params.get('nodeName') or params.get('node')
command = params.get('command')
layer = params.get('layer', 'high_level')
page_id = params.get('page_id', 'page_1')
cmd_params = params.get('params') or {}
timeout = params.get('timeout', 30)
tools = params.get('tools')
return gw.execute_browser_command_sync(nodename, {
'command': command,
'layer': layer,
'page_id': page_id,
'params': cmd_params,
'timeout': timeout,
'tools': tools
}, timeout)
def tool_computer_control(args=None, **kwargs) -> Dict[str, Any]:
"""Execute computer control command on a node"""
gw = _get_gateway()
if args is None:
args = kwargs
node_name = args.get('node_name') or args.get('nodeName') or args.get('node')
action = args.get('action')
cmd_params = args.get('params') or {}
timeout = args.get('timeout', 30)
return gw.execute_computer_control_command_sync(node_name, {
'action': action,
'params': cmd_params
}, timeout)
# ---------------------------------------------------------------------------
......@@ -987,8 +1817,10 @@ def register(ctx):
# Load tokens from external config file
config_path = hermes_config.get('node_gateway', {}).get('config_path',
'~/.config/hermes-node-gateway/config.json')
'/home/lisa/.config/hermes-node-gateway/config.json')
import json
from pathlib import Path
tokens = {}
try:
with open(Path(config_path).expanduser()) as f:
......@@ -1007,10 +1839,10 @@ def register(ctx):
'websocket_port': hermes_config.get('node_gateway', {}).get('websocket_port', 8765),
'use_tls': hermes_config.get('node_gateway', {}).get('use_tls', True),
'cert_dir': hermes_config.get('node_gateway', {}).get('cert_dir',
'~/.config/hermes-node-gateway/certs'),
'/home/lisa/.config/hermes-node-gateway/certs'),
'tokens': tokens,
'permissions_path': hermes_config.get('node_gateway', {}).get('permissions_path',
'~/.config/hermes-node-gateway/permissions.json'),
'/home/lisa/.config/hermes-node-gateway/permissions.json'),
}
# Start the gateway
......
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