Implement secure configuration management with encryption and validation (Phase 1 improvements)

This commit is contained in:
truetype committed 2026-03-19 20:29:30 +01:00
commit d4d6aafea9
12 files changed
+1656

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
# MaxClaw Agent
A configurable agent harness using DeepAgents that supports multiple LLM providers.
## Features
- **Provider Agnostic**: Works with OpenAI, Ollama, OpenRouter, and any other provider supported by LangChain's `init_chat_model`
- **Built on DeepAgents**: Includes planning, filesystem access, subagents, and context management out of the box
- **Easy Configuration**: Set environment variables to choose your provider and model
- **Persistent Configuration**: Store and manage configurations via CLI or TUI
- **Extensible**: Add custom tools, modify prompts, and configure subagents as needed
## Installation
1. Clone this repository
2. Install the required packages:
```bash
pip install -r requirements.txt
```
## Configuration
You can configure MaxClaw Agent in several ways:
### 1. Environment Variables (Quick Start)
Set the following environment variables to configure your LLM provider:
- `MODEL_PROVIDER`: The provider to use (e.g., "openai", "ollama", "openrouter")
- `MODEL_NAME`: The specific model name (e.g., "gpt-4o", "gemma2:9b")
- Provider-specific variables:
- For OpenAI: `OPENAI_API_KEY`
- For Ollama: `OLLAMA_BASE_URL` (defaults to http://localhost:11434)
- For OpenRouter: `OPENROUTER_API_KEY` and optionally `OPENROUTER_BASE_URL` (defaults to https://openrouter.ai/api/v1)
### Example Configurations
#### OpenAI
```bash
export MODEL_PROVIDER="openai"
export MODEL_NAME="gpt-4o"
export OPENAI_API_KEY="your-openai-api-key"
```
#### Ollama (local)
```bash
export MODEL_PROVIDER="ollama"
export MODEL_NAME="gemma2:9b"
export OLLAMA_BASE_URL="http://localhost:11434"
```
#### OpenRouter
```bash
export MODEL_PROVIDER="openrouter"
export MODEL_NAME="anthropic/claude-3.5-sonnet"
export OPENROUTER_API_KEY="your-openrouter-api-key"
```
### 2. Persistent Configuration (Recommended)
Use the CLI or TUI to manage configurations that persist across sessions.
#### CLI Usage
```bash
# List configurations
python config_cli.py list
# Add a new configuration interactively
python config_cli.py add
# Add a new configuration non-interactively
python config_cli.py add --name "my-ollama" --provider "ollama" --model "gemma2:9b" --base-url "https://ollama.solarpi.net" --set-active
# Remove a configuration
python config_cli.py remove "my-ollama"
# Set active configuration
python config_cli.py set-active "my-ollama"
# Show active configuration
python config_cli.py active
```
#### TUI Usage
```bash
# Launch the Text User Interface
python config_tui.py
```
Navigate using arrow keys, press Enter to select, and 'q' to quit.
## Usage
Run the agent:
```bash
python main.py
```
You can then interact with the agent in the terminal. Type 'exit' to quit.
## Customization
To customize the agent, edit `main.py`:
- Change the default system prompt in `create_maxclaw_agent`
- Add custom tools to the `create_deep_agent` call
- Configure subagents or other DeepAgents features
## Testing
Run the test script:
```bash
python test_agent.py
```
## License
MIT
Binary file not shown.
Binary file not shown.
Binary file not shown.
+16
View File
@@ -0,0 +1,16 @@
# Example environment variables for OpenClaw Agent
# Copy this file to .env and fill in your actual values
# Model Provider: openai, ollama, openrouter
MODEL_PROVIDER=openai
MODEL_NAME=gpt-4o-mini
# OpenAI (if MODEL_PROVIDER=openai)
OPENAI_API_KEY=your-openai-api-key-here
# Ollama (if MODEL_PROVIDER=ollama)
OLLAMA_BASE_URL=https://ollama.solarpi.net
# OpenRouter (if MODEL_PROVIDER=openrouter)
OPENROUTER_API_KEY=your-openrouter-api-key-here
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
+378
View File
@@ -0,0 +1,378 @@
"""
Command-line interface for managing MaxClaw Agent configurations
"""
import sys
import argparse
from typing import Optional
from config_manager import config_manager
def print_configurations():
"""Print all configurations in a formatted way"""
configs = config_manager.get_configurations()
active = config_manager.get_active_configuration()
if not configs:
print("No configurations found.")
return
print("\nMaxClaw Agent Configurations:")
print("=" * 50)
for name, config in configs.items():
marker = " * " if name == active else " "
provider = config.get("provider", "unknown")
model = config.get("model_name", "unknown")
print(f"{marker}{name}:")
print(f" Provider: {provider}")
print(f" Model: {model}")
# Show additional info based on provider
if provider == "openai":
print(f" API Key: {'Set' if config.get('api_key') else 'Not set'}")
elif provider == "ollama":
base_url = config.get("base_url", "http://localhost:11434")
print(f" Base URL: {base_url}")
elif provider == "openrouter":
print(f" API Key: {'Set' if config.get('api_key') else 'Not set'}")
base_url = config.get("base_url", "https://openrouter.ai/api/v1")
print(f" Base URL: {base_url}")
print()
def add_configuration_interactive():
"""Interactively add a new configuration"""
print("\nAdd New Configuration")
print("=" * 30)
name = input("Configuration name: ").strip()
if not name:
print("Error: Configuration name cannot be empty")
return
if config_manager.configuration_exists(name):
overwrite = input(f"Configuration '{name}' already exists. Overwrite? (y/N): ").strip().lower()
if overwrite != 'y':
print("Cancelled.")
return
print("\nSelect provider:")
print("1. OpenAI")
print("2. Ollama")
print("3. OpenRouter")
provider_choice = input("Enter choice (1-3): ").strip()
provider_map = {
"1": "openai",
"2": "ollama",
"3": "openrouter"
}
if provider_choice not in provider_map:
print("Invalid choice.")
return
provider = provider_map[provider_choice]
config = {"provider": provider}
# Get model name
model_name = input(f"Model name for {provider} (e.g., gpt-4o, gemma2:9b): ").strip()
if not model_name:
print("Error: Model name cannot be empty")
return
config["model_name"] = model_name
# Get provider-specific settings
if provider == "openai":
api_key = input("OpenAI API key: ").strip()
if not api_key:
print("Warning: API key is required for OpenAI")
config["api_key"] = api_key
elif provider == "ollama":
base_url = input(f"Ollama base URL (default: http://localhost:11434): ").strip()
if not base_url:
base_url = "http://localhost:11434"
config["base_url"] = base_url
elif provider == "openrouter":
api_key = input("OpenRouter API key: ").strip()
if not api_key:
print("Warning: API key is required for OpenRouter")
config["api_key"] = api_key
base_url = input(f"OpenRouter base URL (default: https://openrouter.ai/api/v1): ").strip()
if not base_url:
base_url = "https://openrouter.ai/api/v1"
config["base_url"] = base_url
# Ask if this should be the active configuration
set_active = input("Set as active configuration? (y/N): ").strip().lower()
# Save the configuration
try:
config_manager.add_configuration(name, config)
print(f"\nConfiguration '{name}' saved successfully.")
if set_active == 'y':
config_manager.set_active_configuration(name)
print(f"Configuration '{name}' set as active.")
except ValueError as e:
print(f"Error: {e}")
def remove_configuration_interactive():
"""Interactively remove a configuration"""
configs = config_manager.get_configurations()
if not configs:
print("No configurations to remove.")
return
print("\nRemove Configuration")
print("=" * 30)
print_configurations()
name = input("Enter configuration name to remove: ").strip()
if not name:
print("Error: Configuration name cannot be empty")
return
if not config_manager.configuration_exists(name):
print(f"Error: Configuration '{name}' not found.")
return
active = config_manager.get_active_configuration()
if name == active:
confirm = input(f"'{name}' is the active configuration. Remove it anyway? (y/N): ").strip().lower()
if confirm != 'y':
print("Cancelled.")
return
confirm = input(f"Are you sure you want to remove configuration '{name}'? (y/N): ").strip().lower()
if confirm == 'y':
if config_manager.remove_configuration(name):
print(f"Configuration '{name}' removed successfully.")
if name == active:
print("Active configuration has been unset.")
else:
print(f"Error: Failed to remove configuration '{name}'.")
else:
print("Cancelled.")
def set_active_configuration_interactive():
"""Interactively set the active configuration"""
configs = config_manager.get_configurations()
if not configs:
print("No configurations available.")
return
print("\nSet Active Configuration")
print("=" * 30)
print_configurations()
name = input("Enter configuration name to set as active: ").strip()
if not name:
print("Error: Configuration name cannot be empty")
return
if not config_manager.configuration_exists(name):
print(f"Error: Configuration '{name}' not found.")
return
if config_manager.set_active_configuration(name):
print(f"Configuration '{name}' set as active.")
else:
print(f"Error: Failed to set configuration '{name}' as active.")
def show_active_configuration():
"""Show the currently active configuration"""
active_name = config_manager.get_active_configuration()
if not active_name:
print("No active configuration set.")
return
config = config_manager.get_configuration(active_name)
if not config:
print(f"Error: Active configuration '{active_name}' not found.")
return
print("\nActive Configuration")
print("=" * 30)
print(f"Name: {active_name}")
print(f"Provider: {config.get('provider', 'unknown')}")
print(f"Model: {config.get('model_name', 'unknown')}")
# Show additional info based on provider
provider = config.get('provider')
if provider == "openai":
print(f"API Key: {'Set' if config.get('api_key') else 'Not set'}")
elif provider == "ollama":
base_url = config.get('base_url', 'http://localhost:11434')
print(f"Base URL: {base_url}")
elif provider == "openrouter":
print(f"API Key: {'Set' if config.get('api_key') else 'Not set'}")
base_url = config.get('base_url', 'https://openrouter.ai/api/v1')
print(f"Base URL: {base_url}")
def test_configuration():
"""Test connection to a configuration"""
configs = config_manager.get_configurations()
if not configs:
print("No configurations to test.")
return
print("\nTest Configuration Connection")
print("=" * 30)
print_configurations()
name = input("Enter configuration name to test: ").strip()
if not name:
print("Error: Configuration name cannot be empty")
return
if not config_manager.configuration_exists(name):
print(f"Error: Configuration '{name}' not found.")
return
print(f"\nTesting connection to '{name}'...")
success, message = config_manager.test_configuration_connection(name)
if success:
print(f"✅ {message}")
else:
print(f"❌ {message}")
def main():
"""Main CLI entry point"""
parser = argparse.ArgumentParser(description="MaxClaw Agent Configuration Manager")
subparsers = parser.add_subparsers(dest='command', help='Available commands')
# List command
subparsers.add_parser('list', help='List all configurations')
# Add command
add_parser = subparsers.add_parser('add', help='Add a new configuration')
add_parser.add_argument('--non-interactive', action='store_true',
help='Non-interactive mode (requires all parameters)')
add_parser.add_argument('--name', help='Configuration name')
add_parser.add_argument('--provider', choices=['openai', 'ollama', 'openrouter'], help='Provider')
add_parser.add_argument('--model', help='Model name')
add_parser.add_argument('--api-key', help='API key (for OpenAI/OpenRouter)')
add_parser.add_argument('--base-url', help='Base URL (for Ollama/OpenRouter)')
add_parser.add_argument('--set-active', action='store_true', help='Set as active configuration')
# Remove command
remove_parser = subparsers.add_parser('remove', help='Remove a configuration')
remove_parser.add_argument('name', nargs='?', help='Configuration name to remove')
# Set active command
active_parser = subparsers.add_parser('set-active', help='Set active configuration')
active_parser.add_argument('name', nargs='?', help='Configuration name to set as active')
# Show active command
subparsers.add_parser('active', help='Show active configuration')
# Test command
test_parser = subparsers.add_parser('test', help='Test connection to a configuration')
test_parser.add_argument('name', nargs='?', help='Configuration name to test')
args = parser.parse_args()
if args.command == 'list':
print_configurations()
elif args.command == 'add':
if args.non_interactive:
# Non-interactive mode
if not all([args.name, args.provider, args.model]):
print("Error: --name, --provider, and --model are required for non-interactive mode")
return 1
config = {
"provider": args.provider,
"model_name": args.model
}
if args.provider in ['openai', 'openrouter']:
if not args.api_key:
print("Error: --api-key is required for OpenAI/OpenRouter providers")
return 1
config["api_key"] = args.api_key
if args.provider in ['ollama', 'openrouter']:
config["base_url"] = args.base_url or (
"http://localhost:11434" if args.provider == "ollama"
else "https://openrouter.ai/api/v1"
)
try:
config_manager.add_configuration(args.name, config)
print(f"Configuration '{args.name}' added successfully.")
if args.set_active:
config_manager.set_active_configuration(args.name)
print(f"Configuration '{args.name}' set as active.")
except ValueError as e:
print(f"Error: {e}")
return 1
else:
# Interactive mode
add_configuration_interactive()
elif args.command == 'remove':
if args.name:
# Non-interactive removal
if config_manager.remove_configuration(args.name):
print(f"Configuration '{args.name}' removed successfully.")
active = config_manager.get_active_configuration()
if args.name == active:
print("Active configuration has been unset.")
else:
print(f"Error: Configuration '{args.name}' not found.")
return 1
else:
# Interactive removal
remove_configuration_interactive()
elif args.command == 'set-active':
if args.name:
# Non-interactive setting
if config_manager.set_active_configuration(args.name):
print(f"Configuration '{args.name}' set as active.")
else:
print(f"Error: Configuration '{args.name}' not found.")
return 1
else:
# Interactive setting
set_active_configuration_interactive()
elif args.command == 'active':
show_active_configuration()
elif args.command == 'test':
if args.name:
# Non-interactive test
print(f"Testing connection to '{args.name}'...")
success, message = config_manager.test_configuration_connection(args.name)
if success:
print(f"✅ {message}")
else:
print(f"❌ {message}")
return 1
else:
# Interactive test
test_configuration()
else:
parser.print_help()
return 0
if __name__ == "__main__":
sys.exit(main())
+424
View File
@@ -0,0 +1,424 @@
"""
Configuration manager for MaxClaw Agent
Handles persistent storage of provider configurations with encryption and validation
"""
import json
import os
from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple
import copy
from cryptography.fernet import Fernet
import base64
try:
import requests
except ImportError:
requests = None
class ConfigManager:
"""Manages MaxClaw Agent configuration with encryption and validation"""
def __init__(self, config_dir: Optional[str] = None):
"""
Initialize the config manager
Args:
config_dir: Directory to store config files. If None, uses ~/.maxclaw
"""
if config_dir is None:
self.config_dir = Path.home() / ".maxclaw"
else:
self.config_dir = Path(config_dir)
self.config_file = self.config_dir / "config.json"
self.key_file = self.config_dir / ".key"
# Ensure config directory exists with secure permissions
self._ensure_config_dir()
# Load or create encryption key
self._encryption_key = self._load_or_create_key()
self._cipher = Fernet(self._encryption_key)
# Load configuration
self.config = self._load_config()
def _ensure_config_dir(self):
"""Ensure the configuration directory exists with secure permissions"""
self.config_dir.mkdir(parents=True, exist_ok=True)
# Set directory permissions to owner-only (700)
os.chmod(self.config_dir, 0o700)
def _load_or_create_key(self) -> bytes:
"""Load existing encryption key or create a new one"""
if self.key_file.exists():
try:
key = self.key_file.read_bytes()
# Validate key format
Fernet(key) # This will raise an exception if invalid
# Set key file permissions to owner-only (600)
os.chmod(self.key_file, 0o600)
return key
except Exception:
# If key is invalid or corrupted, create a new one
pass
# Generate new key
key = Fernet.generate_key()
self.key_file.write_bytes(key)
# Set key file permissions to owner-only (600)
os.chmod(self.key_file, 0o600)
return key
def _encrypt_data(self, data: str) -> str:
"""Encrypt sensitive data"""
if not data:
return data
encrypted = self._cipher.encrypt(data.encode())
return base64.b64encode(encrypted).decode('utf-8')
def _decrypt_data(self, encrypted_data: str) -> str:
"""Decrypt sensitive data"""
if not encrypted_data:
return encrypted_data
try:
decoded = base64.b64decode(encrypted_data.encode())
decrypted = self._cipher.decrypt(decoded)
return decrypted.decode('utf-8')
except Exception:
# If decryption fails, return as-is (might be unencrypted legacy data)
return encrypted_data
def _load_config(self) -> Dict[str, Any]:
"""Load configuration from file"""
if self.config_file.exists():
try:
with open(self.config_file, 'r') as f:
config = json.load(f)
# Decrypt any encrypted fields in existing config
return self._decrypt_config(config)
except (json.JSONDecodeError, IOError) as e:
# Return default config if file is corrupted
print(f"Warning: Could not load config file ({e}), using defaults")
return self._get_default_config()
else:
# Return default config if file doesn't exist
return self._get_default_config()
def _decrypt_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
"""Decrypt sensitive fields in configuration"""
if not isinstance(config, dict):
return config
# Create a deep copy to avoid modifying original
result = copy.deepcopy(config)
# Decrypt configurations
if "configurations" in result and isinstance(result["configurations"], dict):
for name, config_data in result["configurations"].items():
if isinstance(config_data, dict):
# Decrypt API keys for relevant providers
provider = config_data.get("provider", "").lower()
if provider in ["openai", "openrouter"] and "api_key" in config_data:
config_data["api_key"] = self._decrypt_data(config_data["api_key"])
return result
def _encrypt_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
"""Encrypt sensitive fields in configuration before storage"""
if not isinstance(config, dict):
return config
# Create a deep copy to avoid modifying original
result = copy.deepcopy(config)
# Encrypt configurations
if "configurations" in result and isinstance(result["configurations"], dict):
for name, config_data in result["configurations"].items():
if isinstance(config_data, dict):
# Encrypt API keys for relevant providers
provider = config_data.get("provider", "").lower()
if provider in ["openai", "openrouter"] and "api_key" in config_data:
api_key = config_data["api_key"]
# Only encrypt if not already encrypted (avoid double encryption)
if api_key and not self._is_likely_encrypted(api_key):
config_data["api_key"] = self._encrypt_data(api_key)
return result
def _is_likely_encrypted(self, data: str) -> bool:
"""Heuristic to check if data appears to be encrypted"""
if not data or len(data) < 10:
return False
# Encrypted data with Fernet is base64 encoded and typically longer
try:
# Try to decode as base64
base64.b64decode(data.encode(), validate=True)
# If it decodes successfully and is reasonably long, likely encrypted
return len(data) >= 20
except Exception:
return False
def _get_default_config(self) -> Dict[str, Any]:
"""Get default configuration structure"""
return {
"configurations": {},
"active_configuration": None,
"version": "1.0"
}
def _save_config(self):
"""Save configuration to file with encryption of sensitive data"""
try:
# Encrypt sensitive data before saving
config_to_save = self._encrypt_config(self.config)
# Create a backup of the existing config
if self.config_file.exists():
backup_file = self.config_file.with_suffix('.json.bak')
backup_file.write_text(self.config_file.read_text())
# Set backup file permissions to owner-only (600)
os.chmod(backup_file, 0o600)
# Write the encrypted config
with open(self.config_file, 'w') as f:
json.dump(config_to_save, f, indent=2, sort_keys=True)
# Set config file permissions to owner-only (600)
os.chmod(self.config_file, 0o600)
except IOError as e:
raise Exception(f"Failed to save configuration: {e}")
def validate_configuration(self, config: Dict[str, Any]) -> Tuple[bool, List[str]]:
"""
Validate a configuration dictionary
Returns:
Tuple of (is_valid, list_of_error_messages)
"""
errors = []
# Check if config is a dictionary
if not isinstance(config, dict):
errors.append("Configuration must be a dictionary")
return False, errors
# Validate provider
provider = config.get("provider", "").lower()
valid_providers = ["openai", "ollama", "openrouter"]
if not provider:
errors.append("Provider is required")
elif provider not in valid_providers:
errors.append(f"Unsupported provider: {provider}. Valid options: {', '.join(valid_providers)}")
# Validate model name
model_name = config.get("model_name", "").strip()
if not model_name:
errors.append("Model name is required and cannot be empty")
elif len(model_name) > 100:
errors.append("Model name is too long (maximum 100 characters)")
# Provider-specific validation
if provider == "openai":
api_key = config.get("api_key", "").strip()
if not api_key:
errors.append("API key is required for OpenAI provider")
elif not api_key.startswith("sk-"):
errors.append("OpenAI API key should start with 'sk-'")
elif len(api_key) < 20:
errors.append("OpenAI API key appears too short")
elif provider == "ollama":
base_url = config.get("base_url", "").strip()
if not base_url:
errors.append("Base URL is required for Ollama provider")
else:
# Normalize URL for validation
if not base_url.startswith(("http://", "https://")):
errors.append("Ollama base URL must start with http:// or https://")
else:
# Remove trailing slash for consistency
if base_url.endswith("/"):
config["base_url"] = base_url.rstrip("/")
elif provider == "openrouter":
api_key = config.get("api_key", "").strip()
base_url = config.get("base_url", "").strip()
if not api_key:
errors.append("API key is required for OpenRouter provider")
elif len(api_key) < 10:
errors.append("OpenRouter API key appears too short")
if not base_url:
errors.append("Base URL is required for OpenRouter provider")
elif not base_url.startswith(("http://", "https://")):
errors.append("OpenRouter base URL must start with http:// or https://")
else:
# Remove trailing slash for consistency
if base_url.endswith("/"):
config["base_url"] = base_url.rstrip("/")
return len(errors) == 0, errors
def get_configurations(self) -> Dict[str, Any]:
"""Get all configurations"""
return self.config.get("configurations", {})
def get_active_configuration(self) -> Optional[str]:
"""Get the name of the active configuration"""
return self.config.get("active_configuration")
def get_configuration(self, name: str) -> Optional[Dict[str, Any]]:
"""Get a specific configuration by name"""
configurations = self.get_configurations()
config = configurations.get(name)
# Return a copy to prevent accidental modification of internal data
return copy.deepcopy(config) if config else None
def add_configuration(self, name: str, config: Dict[str, Any]):
"""
Add or update a configuration with validation
Args:
name: Configuration name
config: Configuration dictionary with provider, model, etc.
Raises:
ValueError: If configuration is invalid
"""
if not name or not isinstance(name, str):
raise ValueError("Configuration name must be a non-empty string")
# Validate the configuration
is_valid, errors = self.validate_configuration(config)
if not is_valid:
raise ValueError(f"Invalid configuration: {', '.join(errors)}")
if "configurations" not in self.config:
self.config["configurations"] = {}
# Deep copy to avoid reference issues
self.config["configurations"][name] = copy.deepcopy(config)
self._save_config()
def remove_configuration(self, name: str) -> bool:
"""
Remove a configuration
Args:
name: Configuration name to remove
Returns:
True if removed, False if not found
"""
if "configurations" in self.config and name in self.config["configurations"]:
del self.config["configurations"][name]
# If this was the active configuration, unset it
if self.config.get("active_configuration") == name:
self.config["active_configuration"] = None
self._save_config()
return True
return False
def set_active_configuration(self, name: str) -> bool:
"""
Set the active configuration
Args:
name: Configuration name to set as active
Returns:
True if successful, False if configuration not found
"""
if name in self.get_configurations():
self.config["active_configuration"] = name
self._save_config()
return True
return False
def get_active_config_details(self) -> Optional[Dict[str, Any]]:
"""Get the details of the active configuration"""
active_name = self.get_active_configuration()
if active_name:
return self.get_configuration(active_name)
return None
def list_configuration_names(self) -> List[str]:
"""Get a list of all configuration names"""
return list(self.get_configurations().keys())
def configuration_exists(self, name: str) -> bool:
"""Check if a configuration exists"""
return name in self.get_configurations()
def test_configuration_connection(self, name: str) -> Tuple[bool, str]:
"""
Test connection to a configured provider
Args:
name: Configuration name to test
Returns:
Tuple of (success, message)
"""
config = self.get_configuration(name)
if not config:
return False, f"Configuration '{name}' not found"
provider = config.get("provider", "").lower()
# Check if requests is available
if requests is None:
return False, "The 'requests' library is not available. Please install it to test connections."
try:
if provider == "ollama":
base_url = config.get("base_url", "http://localhost:11434")
# Ensure no trailing slash
if base_url.endswith("/"):
base_url = base_url.rstrip("/")
response = requests.get(f"{base_url}/api/tags", timeout=5)
if response.status_code == 200:
models = response.json().get("models", [])
model_names = [m.get("name", "unknown") for m in models]
return True, f"Connected successfully. Available models: {', '.join(model_names[:5])}{'...' if len(model_names) > 5 else ''}"
else:
return False, f"Ollama server returned status {response.status_code}"
elif provider == "openai":
# For OpenAI, we can't easily test without making an actual API call
# which might incur costs. Just validate the key format.
api_key = config.get("api_key", "")
if api_key and api_key.startswith("sk-") and len(api_key) > 20:
return True, "API key format appears valid (no live test performed to avoid costs)"
else:
return False, "API key format appears invalid"
elif provider == "openrouter":
# Similar to OpenAI, just validate format to avoid costs
api_key = config.get("api_key", "")
base_url = config.get("base_url", "https://openrouter.ai/api/v1")
if api_key and len(api_key) > 10:
return True, "API key format appears valid (no live test performed to avoid costs)"
else:
return False, "API key format appears invalid"
else:
return False, f"Connection testing not implemented for provider: {provider}"
except requests.exceptions.Timeout:
return False, "Connection timeout - please check the URL and network connectivity"
except requests.exceptions.ConnectionError:
return False, "Connection failed - please check the URL and ensure the service is running"
except Exception as e:
return False, f"Connection test failed: {str(e)}"
# Global config manager instance
config_manager = ConfigManager()
+489
View File
@@ -0,0 +1,489 @@
"""
Text User Interface for MaxClaw Agent configuration management
"""
import curses
import sys
from typing import Optional, List, Dict, Any
from config_manager import config_manager
def draw_menu(stdscr, selected_row_idx, options):
"""Draw a menu with options"""
stdscr.clear()
h, w = stdscr.getmaxyx()
# Title
title = "MaxClaw Agent Configuration TUI"
stdscr.attron(curses.A_BOLD)
stdscr.addstr(0, (w - len(title)) // 2, title)
stdscr.attroff(curses.A_BOLD)
# Instructions
instructions = "Use arrow keys to navigate, Enter to select, 'q' to quit"
stdscr.addstr(2, (w - len(instructions)) // 2, instructions)
# Menu options
for idx, option in enumerate(options):
x = w // 2 - len(option) // 2
y = h // 2 - len(options) // 2 + idx
if idx == selected_row_idx:
stdscr.attron(curses.A_REVERSE)
stdscr.addstr(y, x, option)
stdscr.attroff(curses.A_REVERSE)
else:
stdscr.addstr(y, x, option)
stdscr.refresh()
def get_string_input(stdscr, prompt, y, x, max_length=50):
"""Get string input from user"""
curses.echo()
stdscr.addstr(y, x, prompt)
stdscr.refresh()
input_win = curses.newwin(1, max_length, y, x + len(prompt))
input_win.box()
input_win.refresh()
curses.curs_set(1)
result = input_win.getstr(0, 0, max_length).decode('utf-8')
curses.curs_set(0)
curses.noecho()
return result
def get_password_input(stdscr, prompt, y, x, max_length=50):
"""Get password input from user (hidden)"""
curses.noecho()
stdscr.addstr(y, x, prompt)
stdscr.refresh()
input_win = curses.newwin(1, max_length, y, x + len(prompt))
input_win.box()
input_win.refresh()
curses.curs_set(1)
result = ""
while True:
ch = input_win.getch()
if ch in [curses.KEY_ENTER, 10, 13]: # Enter key
break
elif ch in [curses.KEY_BACKSPACE, 127, 8]: # Backspace
if len(result) > 0:
result = result[:-1]
input_win.addstr(0, len(result), " ")
input_win.move(0, len(result))
elif ch >= 32 and ch <= 126 and len(result) < max_length: # Printable characters
result += chr(ch)
input_win.addstr(0, len(result)-1, "*")
input_win.refresh()
curses.curs_set(0)
return result
def show_message(stdscr, message, wait_for_key=True):
"""Show a message to the user"""
h, w = stdscr.getmaxyx()
stdscr.addstr(h//2, (w - len(message)) // 2, message)
stdscr.refresh()
if wait_for_key:
stdscr.getch()
def main_tui(stdscr):
"""Main TUI function"""
# Initialize colors if available
if curses.has_colors():
curses.start_color()
curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE)
# Main menu options
main_options = [
"View Configurations",
"Add Configuration",
"Remove Configuration",
"Set Active Configuration",
"View Active Configuration",
"Exit"
]
current_selection = 0
while True:
draw_menu(stdscr, current_selection, main_options)
key = stdscr.getch()
if key == curses.KEY_UP and current_selection > 0:
current_selection -= 1
elif key == curses.KEY_DOWN and current_selection < len(main_options) - 1:
current_selection += 1
elif key == curses.KEY_ENTER or key in [10, 13]:
# Enter key pressed
if current_selection == 0: # View Configurations
view_configurations_tui(stdscr)
elif current_selection == 1: # Add Configuration
add_configuration_tui(stdscr)
elif current_selection == 2: # Remove Configuration
remove_configuration_tui(stdscr)
elif current_selection == 3: # Set Active Configuration
set_active_configuration_tui(stdscr)
elif current_selection == 4: # View Active Configuration
view_active_configuration_tui(stdscr)
elif current_selection == 5: # Exit
break
elif key == ord('q') or key == ord('Q'):
break
def view_configurations_tui(stdscr):
"""View all configurations in TUI"""
configs = config_manager.get_configurations()
active = config_manager.get_active_configuration()
stdscr.clear()
h, w = stdscr.getmaxyx()
# Title
title = "Configurations"
stdscr.attron(curses.A_BOLD)
stdscr.addstr(0, (w - len(title)) // 2, title)
stdscr.attroff(curses.A_BOLD)
if not configs:
stdscr.addstr(2, 0, "No configurations found.")
else:
# Header
header = f"{'Name':<20} {'Provider':<12} {'Model':<20} {'Active'}"
stdscr.addstr(2, 0, header)
stdscr.addstr(3, 0, "-" * len(header))
# Configurations
for idx, (name, config) in enumerate(configs.items()):
y = 4 + idx
if y >= h - 2: # Prevent overflow
break
marker = "*" if name == active else " "
provider = config.get("provider", "unknown")[:11]
model = config.get("model_name", "unknown")[:19]
line = f"{marker} {name:<19} {provider:<12} {model:<20}"
stdscr.addstr(y, 0, line)
# Instructions
instructions = "Press any key to return to main menu"
stdscr.addstr(h-2, (w - len(instructions)) // 2, instructions)
stdscr.refresh()
stdscr.getch()
def add_configuration_tui(stdscr):
"""Add a new configuration via TUI"""
stdscr.clear()
h, w = stdscr.getmaxyx()
# Title
title = "Add New Configuration"
stdscr.attron(curses.A_BOLD)
stdscr.addstr(0, (w - len(title)) // 2, title)
stdscr.attroff(curses.A_BOLD)
# Get configuration name
name = get_string_input(stdscr, "Configuration name: ", 2, 0)
if not name:
show_message(stdscr, "Error: Configuration name cannot be empty")
return
if config_manager.configuration_exists(name):
overwrite_prompt = f"Configuration '{name}' already exists. Overwrite? (y/n): "
overwrite = get_string_input(stdscr, overwrite_prompt, 3, 0, 1)
if overwrite.lower() != 'y':
show_message(stdscr, "Cancelled.")
return
# Select provider
providers = ["OpenAI", "Ollama", "OpenRouter"]
provider_selection = 0
while True:
stdscr.clear()
stdscr.attron(curses.A_BOLD)
stdscr.addstr(0, (w - len(title)) // 2, title)
stdscr.attroff(curses.A_BOLD)
stdscr.addstr(2, 0, f"Name: {name}")
stdscr.addstr(3, 0, "Select provider:")
for idx, provider in enumerate(providers):
y = 4 + idx
if idx == provider_selection:
stdscr.attron(curses.A_REVERSE)
stdscr.addstr(y, 2, f"> {provider}")
stdscr.attroff(curses.A_REVERSE)
else:
stdscr.addstr(y, 2, f" {provider}")
stdscr.addstr(h-2, 0, "Use arrow keys to select, Enter to confirm")
stdscr.refresh()
key = stdscr.getch()
if key == curses.KEY_UP and provider_selection > 0:
provider_selection -= 1
elif key == curses.KEY_DOWN and provider_selection < len(providers) - 1:
provider_selection += 1
elif key == curses.KEY_ENTER or key in [10, 13]:
break
provider_map = {"OpenAI": "openai", "Ollama": "ollama", "OpenRouter": "openrouter"}
provider = provider_map[providers[provider_selection]]
config = {"provider": provider}
# Get model name
model_name = get_string_input(stdscr, f"Model name for {provider} (e.g., gpt-4o, gemma2:9b): ", 5, 0)
if not model_name:
show_message(stdscr, "Error: Model name cannot be empty")
return
config["model_name"] = model_name
# Get provider-specific settings
y_pos = 6
if provider == "openai":
api_key = get_password_input(stdscr, "OpenAI API key: ", y_pos, 0)
config["api_key"] = api_key
y_pos += 1
elif provider == "ollama":
base_url = get_string_input(stdscr, f"Ollama base URL (default: http://localhost:11434): ", y_pos, 0)
if not base_url:
base_url = "http://localhost:11434"
config["base_url"] = base_url
y_pos += 1
elif provider == "openrouter":
api_key = get_password_input(stdscr, "OpenRouter API key: ", y_pos, 0)
config["api_key"] = api_key
y_pos += 1
base_url = get_string_input(stdscr, f"OpenRouter base URL (default: https://openrouter.ai/api/v1): ", y_pos, 0)
if not base_url:
base_url = "https://openrouter.ai/api/v1"
config["base_url"] = base_url
y_pos += 1
# Ask if this should be the active configuration
set_active_prompt = "Set as active configuration? (y/n): "
set_active = get_string_input(stdscr, set_active_prompt, y_pos, 0, 1)
# Save the configuration
try:
config_manager.add_configuration(name, config)
message = f"Configuration '{name}' saved successfully."
if set_active.lower() == 'y':
config_manager.set_active_configuration(name)
message += f" Configuration '{name}' set as active."
show_message(stdscr, message)
except Exception as e:
show_message(stdscr, f"Error saving configuration: {e}")
def remove_configuration_tui(stdscr):
"""Remove a configuration via TUI"""
configs = config_manager.get_configurations()
if not configs:
show_message(stdscr, "No configurations to remove.")
return
stdscr.clear()
h, w = stdscr.getmaxyx()
# Title
title = "Remove Configuration"
stdscr.attron(curses.A_BOLD)
stdscr.addstr(0, (w - len(title)) // 2, title)
stdscr.attroff(curses.A_BOLD)
# List configurations
config_names = list(configs.keys())
selection = 0
while True:
stdscr.clear()
stdscr.attron(curses.A_BOLD)
stdscr.addstr(0, (w - len(title)) // 2, title)
stdscr.attroff(curses.A_BOLD)
stdscr.addstr(2, 0, "Select configuration to remove:")
for idx, name in enumerate(config_names):
y = 3 + idx
if y >= h - 4: # Prevent overflow
break
active_marker = " *"
if name == config_manager.get_active_configuration():
active_marker = " (active)"
if idx == selection:
stdscr.attron(curses.A_REVERSE)
stdscr.addstr(y, 2, f"> {name}{active_marker}")
stdscr.attroff(curses.A_REVERSE)
else:
stdscr.addstr(y, 2, f" {name}{active_marker}")
stdscr.addstr(h-2, 0, "Use arrow keys to select, Enter to confirm, 'c' to cancel")
stdscr.refresh()
key = stdscr.getch()
if key == curses.KEY_UP and selection > 0:
selection -= 1
elif key == curses.KEY_DOWN and selection < len(config_names) - 1:
selection += 1
elif key == curses.KEY_ENTER or key in [10, 13]:
# Confirm removal
name_to_remove = config_names[selection]
active = config_manager.get_active_configuration()
if name_to_remove == active:
confirm_prompt = f"'{name_to_remove}' is the active configuration. Remove it anyway? (y/n): "
confirm = get_string_input(stdscr, confirm_prompt, h-3, 0, 1)
if confirm.lower() != 'y':
show_message(stdscr, "Cancelled.")
return
confirm_prompt = f"Are you sure you want to remove configuration '{name_to_remove}'? (y/n): "
confirm = get_string_input(stdscr, confirm_prompt, h-3, 0, 1)
if confirm.lower() == 'y':
if config_manager.remove_configuration(name_to_remove):
show_message(stdscr, f"Configuration '{name_to_remove}' removed successfully.")
if name_to_remove == active:
show_message(stdscr, "Active configuration has been unset.")
else:
show_message(stdscr, f"Error: Failed to remove configuration '{name_to_remove}'.")
else:
show_message(stdscr, "Cancelled.")
return
elif key == ord('c') or key == ord('C'):
show_message(stdscr, "Cancelled.")
return
def set_active_configuration_tui(stdscr):
"""Set active configuration via TUI"""
configs = config_manager.get_configurations()
if not configs:
show_message(stdscr, "No configurations available.")
return
stdscr.clear()
h, w = stdscr.getmaxyx()
# Title
title = "Set Active Configuration"
stdscr.attron(curses.A_BOLD)
stdscr.addstr(0, (w - len(title)) // 2, title)
stdscr.attroff(curses.A_BOLD)
# List configurations
config_names = list(configs.keys())
selection = 0
while True:
stdscr.clear()
stdscr.attron(curses.A_BOLD)
stdscr.addstr(0, (w - len(title)) // 2, title)
stdscr.attroff(curses.A_BOLD)
stdscr.addstr(2, 0, "Select configuration to set as active:")
for idx, name in enumerate(config_names):
y = 3 + idx
if y >= h - 4: # Prevent overflow
break
current_active = config_manager.get_active_configuration()
active_marker = " (current)" if name == current_active else ""
if idx == selection:
stdscr.attron(curses.A_REVERSE)
stdscr.addstr(y, 2, f"> {name}{active_marker}")
stdscr.attroff(curses.A_REVERSE)
else:
stdscr.addstr(y, 2, f" {name}{active_marker}")
stdscr.addstr(h-2, 0, "Use arrow keys to select, Enter to confirm, 'c' to cancel")
stdscr.refresh()
key = stdscr.getch()
if key == curses.KEY_UP and selection > 0:
selection -= 1
elif key == curses.KEY_DOWN and selection < len(config_names) - 1:
selection += 1
elif key == curses.KEY_ENTER or key in [10, 13]:
# Set as active
name_to_set = config_names[selection]
if config_manager.set_active_configuration(name_to_set):
show_message(stdscr, f"Configuration '{name_to_set}' set as active.")
else:
show_message(stdscr, f"Error: Failed to set configuration '{name_to_set}' as active.")
return
elif key == ord('c') or key == ord('C'):
show_message(stdscr, "Cancelled.")
return
def view_active_configuration_tui(stdscr):
"""View active configuration via TUI"""
active_name = config_manager.get_active_configuration()
if not active_name:
show_message(stdscr, "No active configuration set.")
return
config = config_manager.get_configuration(active_name)
if not config:
show_message(stdscr, f"Error: Active configuration '{active_name}' not found.")
return
stdscr.clear()
h, w = stdscr.getmaxyx()
# Title
title = "Active Configuration"
stdscr.attron(curses.A_BOLD)
stdscr.addstr(0, (w - len(title)) // 2, title)
stdscr.attroff(curses.A_BOLD)
# Display configuration details
details = [
f"Name: {active_name}",
f"Provider: {config.get('provider', 'unknown')}",
f"Model: {config.get('model_name', 'unknown')}"
]
# Add provider-specific details
provider = config.get('provider')
if provider == "openai":
details.append(f"API Key: {'Set' if config.get('api_key') else 'Not set'}")
elif provider == "ollama":
base_url = config.get('base_url', 'http://localhost:11434')
details.append(f"Base URL: {base_url}")
elif provider == "openrouter":
details.append(f"API Key: {'Set' if config.get('api_key') else 'Not set'}")
base_url = config.get('base_url', 'https://openrouter.ai/api/v1')
details.append(f"Base URL: {base_url}")
# Display details
for idx, detail in enumerate(details):
y = 2 + idx
if y >= h - 2: # Prevent overflow
break
stdscr.addstr(y, 0, detail)
# Instructions
instructions = "Press any key to return to main menu"
stdscr.addstr(h-2, (w - len(instructions)) // 2, instructions)
stdscr.refresh()
stdscr.getch()
def main():
"""Main entry point for the TUI"""
curses.wrapper(main_tui)
if __name__ == "__main__":
main()
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""
Example usage of OpenClaw Agent with different LLM providers
"""
import os
import sys
sys.path.insert(0, '.')
from main import create_maxclaw_agent
def example_with_openai():
"""Example using OpenAI"""
print("=== Example: OpenAI ===")
os.environ["MODEL_PROVIDER"] = "openai"
os.environ["MODEL_NAME"] = "gpt-4o-mini"
# You would need to set OPENAI_API_KEY in your environment
# os.environ["OPENAI_API_KEY"] = "your-api-key"
try:
agent = create_maxclaw_agent()
print("✅ OpenAI agent created")
# Uncomment to test (requires API key)
# result = agent.invoke({"messages": [{"role": "user", "content": "Say hello in Italian"}]})
# print(f"Response: {result['messages'][-1].content}")
except Exception as e:
print(f"❌ OpenAI agent failed: {e}")
print("💡 Set OPENAI_API_KEY environment variable to test")
def example_with_ollama():
"""Example using Ollama"""
print("\n=== Example: Ollama ===")
os.environ["MODEL_PROVIDER"] = "ollama"
os.environ["MODEL_NAME"] = "gemma2:9b"
os.environ["OLLAMA_BASE_URL"] = "https://ollama.solarpi.net" # From user's profile
try:
agent = create_maxclaw_agent()
print("✅ Ollama agent created")
# Uncomment to test (requires Ollama server)
# result = agent.invoke({"messages": [{"role": "user", "content": "Say hello in Italian"}]})
# print(f"Response: {result['messages'][-1].content}")
except Exception as e:
print(f"❌ Ollama agent failed: {e}")
print("💡 Make sure Ollama is running at https://ollama.solarpi.net")
def example_with_openrouter():
"""Example using OpenRouter"""
print("\n=== Example: OpenRouter ===")
os.environ["MODEL_PROVIDER"] = "openrouter"
os.environ["MODEL_NAME"] = "anthropic/claude-3.5-sonnet"
# You would need to set OPENROUTER_API_KEY in your environment
# os.environ["OPENROUTER_API_KEY"] = "your-api-key"
try:
agent = create_maxclaw_agent()
print("✅ OpenRouter agent created")
# Uncomment to test (requires API key)
# result = agent.invoke({"messages": [{"role": "user", "content": "Say hello in Italian"}]})
# print(f"Response: {result['messages'][-1].content}")
except Exception as e:
print(f"❌ OpenRouter agent failed: {e}")
print("💡 Set OPENROUTER_API_KEY environment variable to test")
if __name__ == "__main__":
print("OpenClaw Agent - Provider Configuration Examples\n")
example_with_openai()
example_with_ollama()
example_with_openrouter()
print("\n💡 To run the agent interactively, use: python main.py")
print(" Remember to set the appropriate environment variables for your chosen provider.")
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""
MaxClaw Agent - A configurable agent harness using DeepAgents
Supports multiple LLM providers via LangChain's init_chat_model
"""
import os
import sys
from typing import Optional
from deepagents import create_deep_agent
from langchain.chat_models import init_chat_model
from langchain_core.messages import HumanMessage, SystemMessage
def load_model_from_config():
"""
Load the LLM model based on configuration.
Configuration can come from environment variables or a config file.
For simplicity, we'll use environment variables.
Expected environment variables:
- OPENAI_API_KEY: For OpenAI models
- OLLAMA_BASE_URL: For Ollama (e.g., https://ollama.solarpi.net)
- MODEL_PROVIDER: One of "openai", "ollama", "openrouter", etc.
- MODEL_NAME: The specific model name (e.g., "gpt-4o", "gemma2:9b")
"""
provider = os.getenv("MODEL_PROVIDER", "openai").lower()
model_name = os.getenv("MODEL_NAME", "gpt-4o")
# Configure based on provider
if provider == "openai":
# Uses OPENAI_API_KEY from environment
return init_chat_model(f"openai:{model_name}")
elif provider == "ollama":
# For Ollama, we need to specify the base URL
base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
return init_chat_model(f"ollama:{model_name}", base_url=base_url)
elif provider == "openrouter":
# For OpenRouter, we need OPENROUTER_API_KEY and optionally base URL
# Note: LangChain's init_chat_model for openrouter might require specific setup
# As of now, we assume it's supported via openai-compatible API
base_url = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
api_key = os.getenv("OPENROUTER_API_KEY")
if not api_key:
raise ValueError("OPENROUTER_API_KEY environment variable is required for OpenRouter")
# Using openai-compatible API
return init_chat_model(
f"openai:{model_name}",
base_url=base_url,
api_key=api_key
)
else:
# Fallback to openai for unknown providers (or add more as needed)
return init_chat_model(f"openai:{model_name}")
def create_maxclaw_agent(system_prompt: Optional[str] = None):
"""
Create and return a DeepAgent instance with configurable model.
"""
if system_prompt is None:
system_prompt = """You are MaxClaw, a helpful AI agent that can plan, use tools,
manage context, and delegate to subagents. You are designed to be configurable
to work with various LLM providers."""
model = load_model_from_config()
agent = create_deep_agent(
model=model,
system_prompt=system_prompt
)
return agent
def main():
"""
Main entry point for the MaxClaw agent.
"""
print("🚀 Initializing MaxClaw Agent...")
try:
agent = create_maxclaw_agent()
print("✅ Agent initialized successfully!")
# Simple test interaction
print("\n💬 You can now interact with the agent. Type 'exit' to quit.")
while True:
user_input = input("\n👤 You: ").strip()
if user_input.lower() in ['exit', 'quit']:
print("👋 Goodbye!")
break
if not user_input:
continue
# Invoke the agent
try:
result = agent.invoke({"messages": [HumanMessage(content=user_input)]})
# Extract the AI's response
ai_message = result["messages"][-1]
print(f"\n🤖 MaxClaw: {ai_message.content}")
except Exception as e:
print(f"\n❌ Error: {str(e)}")
except Exception as e:
print(f"❌ Failed to initialize agent: {str(e)}")
sys.exit(1)
if __name__ == "__main__":
main()
+4
View File
@@ -0,0 +1,4 @@
deepagents>=0.0.34
langchain>=0.3.0
langchain-community>=0.3.0
langchain-openai>=0.1.0
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""
Test script for OpenClaw Agent
"""
import os
import sys
sys.path.insert(0, '.')
from main import create_maxclaw_agent
def test_agent_creation():
"""Test that the agent can be created successfully."""
print("Testing agent creation...")
try:
agent = create_maxclaw_agent()
print("✅ Agent created successfully!")
return agent
except Exception as e:
print(f"❌ Failed to create agent: {e}")
print("💡 Make sure to set the required environment variables for your chosen model provider.")
print(" See README.md for configuration examples.")
return None
def test_simple_query(agent):
"""Test a simple query to the agent."""
print("\nTesting simple query...")
try:
result = agent.invoke({"messages": [{"role": "user", "content": "Hello, how are you?"}]})
ai_message = result["messages"][-1]
print(f"✅ Agent responded: {ai_message.content}")
return True
except Exception as e:
print(f"❌ Failed to get response: {e}")
return False
if __name__ == "__main__":
# Set some default environment variables for testing
# In a real scenario, these would be set externally
if not os.getenv("MODEL_PROVIDER"):
os.environ["MODEL_PROVIDER"] = "openai"
if not os.getenv("MODEL_NAME"):
os.environ["MODEL_NAME"] = "gpt-4o-mini" # Using a cheaper model for testing
agent = test_agent_creation()
if agent:
test_simple_query(agent)