Compare commits

..

7 Commits

Author SHA1 Message Date
openhands f10360f416 test: fix unit tests
- Add missing dependency 'markdown' for CLI TUI rendering
- Prevent env var WORKSPACE_MOUNT_PATH_IN_SANDBOX from overriding default when SANDBOX_VOLUMES lacks /workspace

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-28 18:41:13 +00:00
openhands bf13354bbd Make setup process more friendly and welcoming
- Add emojis and encouraging language to setup messages
- Replace technical jargon with conversational tone
- Add visual indicators for different setup steps
- Include completion messages that celebrate user progress
- Update setup script, Makefile, and VS Code build script
2025-08-11 18:15:52 +00:00
Robert Brennan 385acded2c Update SECURITY.md 2025-08-11 09:01:17 -04:00
Robert Brennan ab079488c6 Create SECURITY.md 2025-08-09 14:37:18 -04:00
Boxuan Li 803bdced9c Fix Windows prompt refinement: ensure 'bash' is replaced with 'powershell' in all prompts (#10179)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-08 20:28:36 -07:00
Xingyao Wang 3eecac2003 docs: Add GPT-5 model recommendation and fix pricing display issue (#10177)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-08 19:19:59 +00:00
mamoodi c02e09fc2d Hide Git Settings section from Application settings (#10176)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-08 19:06:40 +00:00
26 changed files with 302 additions and 1210 deletions
+6 -2
View File
@@ -1,13 +1,17 @@
#! /bin/bash
echo "Setting up the environment..."
echo "🚀 Welcome to OpenHands! Let's get your development environment ready..."
# Install pre-commit package
echo "📦 Installing pre-commit to help maintain code quality..."
python -m pip install pre-commit
# Install pre-commit hooks if .git directory exists
if [ -d ".git" ]; then
echo "Installing pre-commit hooks..."
echo "🔧 Setting up pre-commit hooks to keep your code clean..."
pre-commit install
make install-pre-commit-hooks
echo ""
echo "🎉 Setup complete! Your OpenHands development environment is ready!"
echo "💡 You can now start contributing to OpenHands. Happy coding! 🚀"
fi
+28 -28
View File
@@ -23,16 +23,16 @@ RESET=$(shell tput -Txterm sgr0)
# Build
build:
@echo "$(GREEN)Building project...$(RESET)"
@echo "$(GREEN)🚀 Building OpenHands project...$(RESET)"
@$(MAKE) -s check-dependencies
@$(MAKE) -s install-python-dependencies
@$(MAKE) -s install-frontend-dependencies
@$(MAKE) -s install-pre-commit-hooks
@$(MAKE) -s build-frontend
@echo "$(GREEN)Build completed successfully.$(RESET)"
@echo "$(GREEN)🎉 Build completed successfully! You're ready to go!$(RESET)"
check-dependencies:
@echo "$(YELLOW)Checking dependencies...$(RESET)"
@echo "$(YELLOW)🔍 Checking your development environment...$(RESET)"
@$(MAKE) -s check-system
@$(MAKE) -s check-python
@$(MAKE) -s check-npm
@@ -42,7 +42,7 @@ ifeq ($(INSTALL_DOCKER),)
endif
@$(MAKE) -s check-poetry
@$(MAKE) -s check-tmux
@echo "$(GREEN)Dependencies checked successfully.$(RESET)"
@echo "$(GREEN)✅ All dependencies look great!$(RESET)"
check-system:
@echo "$(YELLOW)Checking system...$(RESET)"
@@ -62,11 +62,11 @@ check-system:
fi
check-python:
@echo "$(YELLOW)Checking Python installation...$(RESET)"
@echo "$(YELLOW)🐍 Checking Python installation...$(RESET)"
@if command -v python$(PYTHON_VERSION) > /dev/null; then \
echo "$(BLUE)$(shell python$(PYTHON_VERSION) --version) is already installed.$(RESET)"; \
echo "$(BLUE)✅ Great! $(shell python$(PYTHON_VERSION) --version) is ready to go.$(RESET)"; \
else \
echo "$(RED)Python $(PYTHON_VERSION) is not installed. Please install Python $(PYTHON_VERSION) to continue.$(RESET)"; \
echo "$(RED)❌ Oops! Python $(PYTHON_VERSION) is not installed. Please install Python $(PYTHON_VERSION) to continue.$(RESET)"; \
exit 1; \
fi
@@ -117,76 +117,76 @@ check-tmux:
fi
check-poetry:
@echo "$(YELLOW)Checking Poetry installation...$(RESET)"
@echo "$(YELLOW)📝 Checking Poetry installation...$(RESET)"
@if command -v poetry > /dev/null; then \
POETRY_VERSION=$(shell poetry --version 2>&1 | sed -E 's/Poetry \(version ([0-9]+\.[0-9]+\.[0-9]+)\)/\1/'); \
IFS='.' read -r -a POETRY_VERSION_ARRAY <<< "$$POETRY_VERSION"; \
if [ $${POETRY_VERSION_ARRAY[0]} -gt 1 ] || ([ $${POETRY_VERSION_ARRAY[0]} -eq 1 ] && [ $${POETRY_VERSION_ARRAY[1]} -ge 8 ]); then \
echo "$(BLUE)$(shell poetry --version) is already installed.$(RESET)"; \
echo "$(BLUE)✅ Perfect! $(shell poetry --version) is ready to manage your dependencies.$(RESET)"; \
else \
echo "$(RED)Poetry 1.8 or later is required. You can install poetry by running the following command, then adding Poetry to your PATH:"; \
echo "$(RED)❌ We need Poetry 1.8 or later. You can install it by running:"; \
echo "$(RED) curl -sSL https://install.python-poetry.org | python$(PYTHON_VERSION) -$(RESET)"; \
echo "$(RED)More detail here: https://python-poetry.org/docs/#installing-with-the-official-installer$(RESET)"; \
echo "$(RED)📖 More details: https://python-poetry.org/docs/#installing-with-the-official-installer$(RESET)"; \
exit 1; \
fi; \
else \
echo "$(RED)Poetry is not installed. You can install poetry by running the following command, then adding Poetry to your PATH:"; \
echo "$(RED)Poetry is not installed. You can install it by running:"; \
echo "$(RED) curl -sSL https://install.python-poetry.org | python$(PYTHON_VERSION) -$(RESET)"; \
echo "$(RED)More detail here: https://python-poetry.org/docs/#installing-with-the-official-installer$(RESET)"; \
echo "$(RED)📖 More details: https://python-poetry.org/docs/#installing-with-the-official-installer$(RESET)"; \
exit 1; \
fi
install-python-dependencies:
@echo "$(GREEN)Installing Python dependencies...$(RESET)"
@echo "$(GREEN)📦 Installing Python dependencies...$(RESET)"
@if [ -z "${TZ}" ]; then \
echo "Defaulting TZ (timezone) to UTC"; \
echo "🌍 Defaulting timezone to UTC"; \
export TZ="UTC"; \
fi
poetry env use python$(PYTHON_VERSION)
@if [ "$(shell uname)" = "Darwin" ]; then \
echo "$(BLUE)Installing chroma-hnswlib...$(RESET)"; \
echo "$(BLUE)🍎 Installing macOS-specific dependencies...$(RESET)"; \
export HNSWLIB_NO_NATIVE=1; \
poetry run pip install chroma-hnswlib; \
fi
@if [ -n "${POETRY_GROUP}" ]; then \
echo "Installing only POETRY_GROUP=${POETRY_GROUP}"; \
echo "📋 Installing specific dependency group: ${POETRY_GROUP}"; \
poetry install --only $${POETRY_GROUP}; \
else \
poetry install --with dev,test,runtime; \
fi
@if [ "${INSTALL_PLAYWRIGHT}" != "false" ] && [ "${INSTALL_PLAYWRIGHT}" != "0" ]; then \
if [ -f "/etc/manjaro-release" ]; then \
echo "$(BLUE)Detected Manjaro Linux. Installing Playwright dependencies...$(RESET)"; \
echo "$(BLUE)🐧 Detected Manjaro Linux. Installing browser automation tools...$(RESET)"; \
poetry run pip install playwright; \
poetry run playwright install chromium; \
else \
if [ ! -f cache/playwright_chromium_is_installed.txt ]; then \
echo "Running playwright install --with-deps chromium..."; \
echo "🌐 Installing browser automation tools..."; \
poetry run playwright install --with-deps chromium; \
mkdir -p cache; \
touch cache/playwright_chromium_is_installed.txt; \
else \
echo "Setup already done. Skipping playwright installation."; \
echo "✅ Browser tools already set up. Skipping installation."; \
fi \
fi \
else \
echo "Skipping Playwright installation (INSTALL_PLAYWRIGHT=${INSTALL_PLAYWRIGHT})."; \
echo "⏭️ Skipping browser automation setup (INSTALL_PLAYWRIGHT=${INSTALL_PLAYWRIGHT})."; \
fi
@echo "$(GREEN)Python dependencies installed successfully.$(RESET)"
@echo "$(GREEN)🎉 Python dependencies installed successfully!$(RESET)"
install-frontend-dependencies: check-npm check-nodejs
@echo "$(YELLOW)Setting up frontend environment...$(RESET)"
@echo "$(YELLOW)Detect Node.js version...$(RESET)"
@echo "$(YELLOW)🎨 Setting up frontend environment...$(RESET)"
@echo "$(YELLOW)🔍 Detecting Node.js version...$(RESET)"
@cd frontend && node ./scripts/detect-node-version.js
echo "$(BLUE)Installing frontend dependencies with npm...$(RESET)"
echo "$(BLUE)📦 Installing frontend dependencies with npm...$(RESET)"
@cd frontend && npm install
@echo "$(GREEN)Frontend dependencies installed successfully.$(RESET)"
@echo "$(GREEN)Frontend dependencies installed successfully!$(RESET)"
install-pre-commit-hooks: check-python check-poetry install-python-dependencies
@echo "$(YELLOW)Installing pre-commit hooks...$(RESET)"
@echo "$(YELLOW)🔧 Installing pre-commit hooks...$(RESET)"
@git config --unset-all core.hooksPath || true
@poetry run pre-commit install --config $(PRE_COMMIT_CONFIG_PATH)
@echo "$(GREEN)Pre-commit hooks installed successfully.$(RESET)"
@echo "$(GREEN)Pre-commit hooks installed successfully!$(RESET)"
lint-backend: install-pre-commit-hooks
@echo "$(YELLOW)Running linters...$(RESET)"
+15
View File
@@ -0,0 +1,15 @@
# Security Policy
**Please send all vulnerability reports to contact@all-hands.dev in addition to opening a security advisory on GitHub.**
## Security/Bugfix Versions
Security and bug fixes are generally provided only for the most recent version of OpenHands. Fixes are released either as part of the next minor version or as an on-demand patch version.
Security fixes are given priority and might be enough to cause a new version to be released.
## Reporting a Vulnerability
We encourage responsible disclosure of security vulnerabilities. If you find something suspicious, we encourage and appreciate your report!
### Ways to report
In order for the vulnerability reports to reach maintainers as soon as possible, the preferred way is to use the "Report a vulnerability" button under the "Security" tab of the associated GitHub project. This creates a private communication channel between the reporter and the maintainers.
In addition, please also reach out to the All Hands AI security team at contact@all-hands.dev.
+12 -12
View File
@@ -55,11 +55,11 @@ def build_vscode_extension():
print(f'--- Using pre-built VS Code extension: {vsix_path} ---')
return
print(f'--- Building VS Code extension in {VSCODE_EXTENSION_DIR} ---')
print(f'🔨 Building VS Code extension in {VSCODE_EXTENSION_DIR}')
try:
# Ensure npm dependencies are installed
print('--- Running npm install for VS Code extension ---')
print('📦 Installing dependencies for VS Code extension...')
subprocess.run(
['npm', 'install'],
cwd=VSCODE_EXTENSION_DIR,
@@ -68,7 +68,7 @@ def build_vscode_extension():
)
# Package the extension
print(f'--- Packaging VS Code extension ({VSIX_FILENAME}) ---')
print(f'📦 Packaging VS Code extension ({VSIX_FILENAME})...')
subprocess.run(
['npm', 'run', 'package-vsix'],
cwd=VSCODE_EXTENSION_DIR,
@@ -82,14 +82,14 @@ def build_vscode_extension():
f'VS Code extension package not found after build: {vsix_path}'
)
print(f'--- VS Code extension built successfully: {vsix_path} ---')
print(f'🎉 VS Code extension built successfully: {vsix_path}')
except subprocess.CalledProcessError as e:
print(f'--- Warning: Failed to build VS Code extension: {e} ---')
print('--- Continuing without building extension ---')
print(f'⚠️ Warning: Failed to build VS Code extension: {e}')
print('⏭️ Continuing without building extension...')
if not vsix_path.exists():
print('--- Warning: No pre-built VS Code extension found ---')
print('--- VS Code extension will not be available ---')
print('⚠️ Warning: No pre-built VS Code extension found')
print(' VS Code extension will not be available')
def build(setup_kwargs):
@@ -97,7 +97,7 @@ def build(setup_kwargs):
This function is called by Poetry during the build process.
`setup_kwargs` is a dictionary that will be passed to `setuptools.setup()`.
"""
print('--- Running custom Poetry build script (build_vscode.py) ---')
print('🔧 Running custom Poetry build script for VS Code extension...')
# Build the VS Code extension and place the .vsix file
build_vscode_extension()
@@ -105,10 +105,10 @@ def build(setup_kwargs):
# Poetry will handle including files based on pyproject.toml `include` patterns.
# Ensure openhands/integrations/vscode/*.vsix is included there.
print('--- Custom Poetry build script (build_vscode.py) finished ---')
print(' Custom Poetry build script completed!')
if __name__ == '__main__':
print('Running build_vscode.py directly for testing VS Code extension packaging...')
print('🧪 Testing VS Code extension packaging...')
build_vscode_extension()
print('Direct execution of build_vscode.py finished.')
print('✅ VS Code extension packaging test completed!')
+3 -18
View File
@@ -360,28 +360,13 @@ classpath = "my_package.my_module.MyCustomAgent"
[security]
# Enable confirmation mode (For Headless / CLI only - In Web this is overridden by Session Init)
# When using command_approval analyzer, this should be enabled
confirmation_mode = true
#confirmation_mode = false
# The security analyzer to use (For Headless / CLI only - In Web this is overridden by Session Init)
# Available options: "invariant", "command_approval"
# For CLI with confirmation mode, "command_approval" is recommended
security_analyzer = "command_approval"
#security_analyzer = ""
# Whether to enable security analyzer
# When using command_approval analyzer, this should be enabled
enable_security_analyzer = true
# Dictionary of approved commands that don't require confirmation
# The key is the command, and the value is a boolean (true to approve)
#approved_commands = { "ls -la" = true, "git status" = true }
# List of approved command patterns (regex) that don't require confirmation
#approved_command_patterns = [
# { pattern = "^ls( -[a-zA-Z]+)?( \\S+)?$", description = "List directory contents" },
# { pattern = "^cd \\S+$", description = "Change directory" },
# { pattern = "^git (status|log|diff)$", description = "Basic git commands" }
#]
#enable_security_analyzer = false
#################################### Condenser #################################
# Condensers control how conversation history is managed and compressed when
+1 -1
View File
@@ -18,7 +18,7 @@ Based on these findings and community feedback, these are the latest models that
### Cloud / API-Based Models
- [anthropic/claude-sonnet-4-20250514](https://www.anthropic.com/api) (recommended)
- [openai/o4-mini](https://openai.com/index/introducing-o3-and-o4-mini/)
- [openai/gpt-5-2025-08-07](https://openai.com/api/) (recommended)
- [gemini/gemini-2.5-pro](https://blog.google/technology/google-deepmind/gemini-model-thinking-updates-march-2025/)
- [deepseek/deepseek-chat](https://api-docs.deepseek.com/)
- [moonshot/kimi-k2-0711-preview](https://platform.moonshot.ai/docs/pricing/chat#generation-model-kimi-k2)
+1 -1
View File
@@ -32,4 +32,4 @@ When running OpenHands, you'll need to set the following in the OpenHands UI thr
Pricing follows official API provider rates. [You can view model prices here.](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json)
For `qwen3-coder-480b`, we charge the cheapest FP8 rate available on openrouter: $0.4 per million input tokens and $1.6 per million output tokens.
For `qwen3-coder-480b`, we charge the cheapest FP8 rate available on openrouter: \$0.4 per million input tokens and \$1.6 per million output tokens.
+1 -1
View File
@@ -222,7 +222,7 @@ function AppSettingsScreen() {
className="w-full max-w-[680px]" // Match the width of the language field
/>
<div className="border-t border-t-tertiary pt-6 mt-2">
<div className="border-t border-t-tertiary pt-6 mt-2 hidden">
<h3 className="text-lg font-medium mb-4">
{t(I18nKey.SETTINGS$GIT_SETTINGS)}
</h3>
+11 -1
View File
@@ -1,3 +1,4 @@
import re
import sys
from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk
@@ -37,7 +38,16 @@ _SHORT_BASH_DESCRIPTION = """Execute a bash command in the terminal.
def refine_prompt(prompt: str):
if sys.platform == 'win32':
return prompt.replace('bash', 'powershell')
# Replace 'bash' with 'powershell' including tool names like 'execute_bash'
# First replace 'execute_bash' with 'execute_powershell' to handle tool names
result = re.sub(
r'\bexecute_bash\b', 'execute_powershell', prompt, flags=re.IGNORECASE
)
# Then replace standalone 'bash' with 'powershell'
result = re.sub(
r'(?<!execute_)(?<!_)\bbash\b', 'powershell', result, flags=re.IGNORECASE
)
return result
return prompt
+2 -37
View File
@@ -236,43 +236,8 @@ async def run_session(
)
return
# Get the pending action from the agent controller
pending_action = controller._pending_action
command = ''
if pending_action:
if hasattr(pending_action, 'command'):
command = pending_action.command
elif hasattr(pending_action, 'code'):
command = pending_action.code
confirmation_status = await read_confirmation_input(
config, command, pending_action
)
# Handle different confirmation responses
if confirmation_status == 'always':
# Set always confirm mode to skip future confirmations
always_confirm_mode = True
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_CONFIRMED),
EventSource.USER,
)
elif confirmation_status.startswith('remember:'):
# Parse the remember response: remember:pattern:description
parts = confirmation_status.split(':', 2)
if len(parts) == 3:
_, pattern, description = parts
# Save the command pattern to config
from openhands.cli.utils import save_approved_command_to_config
save_approved_command_to_config(
command, pattern=pattern, description=description
)
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_CONFIRMED),
EventSource.USER,
)
elif confirmation_status == 'yes':
confirmation_status = await read_confirmation_input(config)
if confirmation_status in ('yes', 'always'):
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_CONFIRMED),
EventSource.USER,
+3 -213
View File
@@ -700,140 +700,12 @@ async def read_prompt_input(
return '/exit'
def _generate_single_command_pattern(command: str) -> list[str]:
"""Generate candidate regex patterns for a single command based on prefixes.
Args:
command: The single command to generate patterns for.
Returns:
list[str]: List of candidate regex patterns that match similar commands.
"""
import re
# Split command into parts
parts = command.split()
if not parts:
return [f'^{re.escape(command)}$']
patterns = []
# Generate patterns for first word, first two words, first three words
for i in range(1, min(4, len(parts) + 1)):
prefix_parts = parts[:i]
escaped_prefix = '\\s+'.join(re.escape(part) for part in prefix_parts)
# Create pattern: prefix followed by word boundary and anything until end of line
# This prevents matching partial words (e.g., "ls" won't match "lsof")
if i == 1:
# For single word, add word boundary to prevent partial matches
pattern = f'^{escaped_prefix}(\\s.*|$)'
else:
# For multiple words, the space already acts as a boundary
pattern = f'^{escaped_prefix}.*$'
patterns.append(pattern)
return patterns
def _parse_piped_command(command: str) -> list[str]:
"""Parse a command that may contain pipes into individual commands.
Args:
command: The command string to parse.
Returns:
list[str]: List of individual commands split by pipes.
"""
import shlex
# Handle edge cases first
if not command or not command.strip():
return []
# Use shlex to split the command, which handles quotes and escapes properly
try:
# Split the entire command into tokens
tokens = shlex.split(command, posix=True)
except ValueError:
# If shlex fails (e.g., unmatched quotes), fall back to simple split
return [part.strip() for part in command.split('|') if part.strip()]
# Find pipe tokens and split the command accordingly
parts = []
current_part: list[str] = []
for token in tokens:
if token == '|':
if current_part:
parts.append(' '.join(current_part))
current_part = []
else:
current_part.append(token)
# Add the last part if it exists
if current_part:
parts.append(' '.join(current_part))
# If no pipes were found in tokens, check if the original command has pipes
# This handles cases where pipes are not separated by spaces
if len(parts) <= 1 and '|' in command:
return [part.strip() for part in command.split('|') if part.strip()]
return parts
def _generate_command_patterns(command: str) -> list[str]:
"""Generate candidate regex patterns for a command that can match similar commands.
This function handles both simple commands and piped commands.
For piped commands, it generates patterns for each sub-command.
Args:
command: The command to generate patterns for (may contain pipes).
Returns:
list[str]: List of candidate regex patterns that match similar commands.
"""
# Parse the command to handle pipes
sub_commands = _parse_piped_command(command)
if len(sub_commands) <= 1:
# Single command or empty, return all candidate patterns
return _generate_single_command_pattern(command)
else:
# Piped command, generate pattern for each sub-command
# For simplicity, we'll just use the first pattern from each sub-command
sub_patterns = []
for sub_command in sub_commands:
patterns = _generate_single_command_pattern(sub_command.strip())
if patterns:
# Use the most specific pattern (first one)
sub_pattern = patterns[0]
# Remove the ^ and $ anchors from sub-patterns
if sub_pattern.startswith('^'):
sub_pattern = sub_pattern[1:]
if sub_pattern.endswith('$'):
sub_pattern = sub_pattern[:-1]
sub_patterns.append(sub_pattern)
if sub_patterns:
# Join sub-patterns with pipe separator (allowing flexible whitespace around pipes)
pattern = '\\s*\\|\\s*'.join(sub_patterns)
return [f'^{pattern}$']
else:
return []
async def read_confirmation_input(
config: OpenHandsConfig, command: str = '', pending_action=None
) -> str:
async def read_confirmation_input(config: OpenHandsConfig) -> str:
try:
choices = [
'Yes, proceed',
'No (and allow to enter instructions)',
'Always proceed (skip all confirmations)',
"Yes, and don't ask again for similar commands",
"Always proceed (don't ask again)",
]
# keep the outer coroutine responsive by using asyncio.to_thread which puts the blocking call app.run() of cli_confirm() in a separate thread
@@ -841,89 +713,7 @@ async def read_confirmation_input(
cli_confirm, config, 'Choose an option:', choices
)
result = {0: 'yes', 1: 'no', 2: 'always', 3: 'remember'}.get(index, 'no')
# If the user chose "remember", show pattern selection options
if result == 'remember' and command:
return await _handle_pattern_selection(config, command)
return result
except (KeyboardInterrupt, EOFError):
return 'no'
async def _handle_pattern_selection(config: OpenHandsConfig, command: str) -> str:
"""Handle pattern selection when user chooses to remember similar commands."""
try:
# Generate candidate patterns
patterns = _generate_command_patterns(command)
# Create pattern descriptions
pattern_choices = []
pattern_data = []
# Add exact command option
import re
exact_pattern = f'^{re.escape(command)}$'
pattern_choices.append(f'Exact command: {command}')
pattern_data.append(('exact', exact_pattern, f'Exact command: {command}'))
# Add prefix-based patterns
parts = command.split()
if parts:
for i, pattern in enumerate(patterns):
if i < len(parts):
prefix = ' '.join(parts[: i + 1])
description = f'Commands starting with: {prefix}'
pattern_choices.append(description)
pattern_data.append(('pattern', pattern, description))
# Add custom pattern option
pattern_choices.append('Enter custom pattern')
pattern_data.append(('custom', '', 'Custom pattern'))
# Show pattern selection menu
pattern_index = await asyncio.to_thread(
cli_confirm, config, 'Choose which commands to remember:', pattern_choices
)
if pattern_index < len(pattern_data):
pattern_type, pattern_value, description = pattern_data[pattern_index]
if pattern_type == 'custom':
# Get custom pattern from user
prompt_session = create_prompt_session(config)
print_formatted_text(
HTML(
'<gold>Enter a custom regex pattern (example: ^ls.*$ for all ls commands):</gold>'
)
)
try:
custom_pattern = await prompt_session.prompt_async('Pattern: ')
if custom_pattern.strip():
# Validate the regex pattern
import re
try:
re.compile(custom_pattern.strip())
return f'remember:{custom_pattern.strip()}:Custom pattern: {custom_pattern.strip()}'
except re.error:
print_formatted_text(
HTML(
'<ansired>Invalid regex pattern. Using exact command instead.</ansired>'
)
)
return f'remember:^{re.escape(command)}$:Exact command: {command}'
else:
return 'no'
except (KeyboardInterrupt, EOFError):
return 'no'
else:
return f'remember:{pattern_value}:{description}'
return 'no'
return {0: 'yes', 1: 'no', 2: 'always'}.get(index, 'no')
except (KeyboardInterrupt, EOFError):
return 'no'
-79
View File
@@ -243,82 +243,3 @@ def read_file(file_path: str | Path) -> str:
def write_to_file(file_path: str | Path, content: str) -> None:
with open(file_path, 'w') as f:
f.write(content)
def save_approved_command_to_config(
command: str, pattern: str | None = None, description: str | None = None
) -> None:
"""Save an approved command or pattern to the config file.
Args:
command: The command to save as approved.
pattern: Optional regex pattern to save instead of exact command.
description: Optional description for the pattern.
"""
config_path = _LOCAL_CONFIG_FILE_PATH
# Load existing config or create a new one
if config_path.exists():
try:
with open(config_path, 'r') as f:
config_data = toml.load(f)
except Exception as e:
from openhands.core.logger import openhands_logger
openhands_logger.warning(f'Error loading config file: {e}')
config_data = {}
else:
config_data = {}
config_path.parent.mkdir(parents=True, exist_ok=True)
# Ensure security section exists
if 'security' not in config_data:
config_data['security'] = {}
if pattern and description:
# Save as a pattern
if 'approved_command_patterns' not in config_data['security']:
config_data['security']['approved_command_patterns'] = []
# Check if pattern already exists
pattern_exists = any(
p == pattern
for p in config_data['security']['approved_command_patterns']
if isinstance(p, str)
) or any(
p.get('pattern') == pattern
for p in config_data['security']['approved_command_patterns']
if isinstance(p, dict)
)
if not pattern_exists:
# Just save the pattern string directly
config_data['security']['approved_command_patterns'].append(pattern)
from openhands.core.logger import openhands_logger
openhands_logger.info(
f"Pattern '{pattern}' saved to approved command patterns in {config_path}"
)
else:
# Save as exact command
if 'approved_commands' not in config_data['security']:
config_data['security']['approved_commands'] = {}
# Add the command to the approved commands
config_data['security']['approved_commands'][command] = True
from openhands.core.logger import openhands_logger
openhands_logger.info(
f"Command '{command}' saved to approved commands in {config_path}"
)
# Write the updated config back to the file
try:
with open(config_path, 'w') as f:
toml.dump(config_data, f)
except Exception as e:
from openhands.core.logger import openhands_logger
openhands_logger.error(f'Error saving approved command to config: {e}')
+3 -34
View File
@@ -875,40 +875,9 @@ class AgentController:
if self.state.confirmation_mode and (
type(action) is CmdRunAction or type(action) is IPythonRunCellAction
):
# Check if the command is already approved
command = ''
if type(action) is CmdRunAction:
command = action.command
elif type(action) is IPythonRunCellAction:
command = action.code
# Get the security config
import toml
from openhands.core.config import SecurityConfig
# Load security config from the config file
security_config = SecurityConfig()
try:
with open('config.toml', 'r', encoding='utf-8') as f:
config_data = toml.load(f)
if 'security' in config_data:
security_config = SecurityConfig.model_validate(
config_data['security']
)
except Exception:
# If loading fails, use default config
pass
# Check if the command is approved
if security_config.is_command_approved(command):
# Command is already approved, no need for confirmation
action.confirmation_state = ActionConfirmationStatus.CONFIRMED
else:
# Command needs confirmation
action.confirmation_state = (
ActionConfirmationStatus.AWAITING_CONFIRMATION
)
action.confirmation_state = (
ActionConfirmationStatus.AWAITING_CONFIRMATION
)
self._pending_action = action
if not isinstance(action, NullAction):
+1 -47
View File
@@ -1,8 +1,5 @@
from pydantic import BaseModel, ConfigDict, Field, ValidationError
# ApprovedCommandPattern is now just a string containing the regex pattern
ApprovedCommandPattern = str
class SecurityConfig(BaseModel):
"""Configuration for security related functionalities.
@@ -10,33 +7,13 @@ class SecurityConfig(BaseModel):
Attributes:
confirmation_mode: Whether to enable confirmation mode.
security_analyzer: The security analyzer to use.
approved_command_patterns: List of regex patterns for commands that don't require confirmation.
approved_commands: Dictionary of exact commands that have been approved.
"""
confirmation_mode: bool = Field(default=False)
security_analyzer: str | None = Field(default=None)
approved_command_patterns: list[ApprovedCommandPattern] = Field(
default_factory=list
)
approved_commands: dict[str, bool] = Field(default_factory=dict)
model_config = ConfigDict(extra='forbid')
def is_command_approved(self, command: str) -> bool:
"""Check if a command is approved.
This is a stub method that always returns False.
The actual implementation is in CommandApprovalAnalyzer.
Args:
command: The command to check.
Returns:
bool: Always False in this stub implementation.
"""
return False
@classmethod
def from_toml_section(cls, data: dict) -> dict[str, 'SecurityConfig']:
"""
@@ -51,32 +28,9 @@ class SecurityConfig(BaseModel):
# Initialize the result mapping
security_mapping: dict[str, SecurityConfig] = {}
# Extract approved command patterns if present
approved_patterns = []
if 'approved_command_patterns' in data:
patterns_data = data.pop('approved_command_patterns')
if isinstance(patterns_data, list):
for pattern_data in patterns_data:
# Handle the new format (just a string pattern)
if isinstance(pattern_data, str):
approved_patterns.append(pattern_data)
# Handle the old format (dict with pattern and description)
elif isinstance(pattern_data, dict) and 'pattern' in pattern_data:
approved_patterns.append(pattern_data['pattern'])
# Extract approved commands if present
approved_commands = {}
if 'approved_commands' in data:
commands_data = data.pop('approved_commands')
if isinstance(commands_data, dict):
approved_commands = commands_data
# Try to create the configuration instance
try:
config = cls.model_validate(data)
config.approved_command_patterns = approved_patterns
config.approved_commands = approved_commands
security_mapping['security'] = config
security_mapping['security'] = cls.model_validate(data)
except ValidationError as e:
raise ValueError(f'Invalid security configuration: {e}')
+11
View File
@@ -77,6 +77,17 @@ def load_from_env(
set_attr_from_env(field_value, prefix=field_name + '_')
elif env_var_name in env_or_toml_dict:
# Special case: avoid overriding workspace_mount_path_in_sandbox from env
# when SANDBOX_VOLUMES is set without an explicit /workspace mount.
if (
isinstance(sub_config, OpenHandsConfig)
and field_name == 'workspace_mount_path_in_sandbox'
):
vols = env_or_toml_dict.get('SANDBOX_VOLUMES')
if vols and '/workspace' not in str(vols):
# Skip overriding; keep the default '/workspace'
continue
# convert the env var to the correct type and set it
value = env_or_toml_dict[env_var_name]
+1 -1
View File
@@ -383,7 +383,7 @@ Do NOT assume the environment is the same as in the example above.
"""
example = example.lstrip()
return example
return refine_prompt(example)
IN_CONTEXT_LEARNING_EXAMPLE_PREFIX = get_example_for_tools
-2
View File
@@ -1,9 +1,7 @@
from openhands.security.analyzer import SecurityAnalyzer
from openhands.security.command_approval.analyzer import CommandApprovalAnalyzer
from openhands.security.invariant.analyzer import InvariantAnalyzer
__all__ = [
'SecurityAnalyzer',
'InvariantAnalyzer',
'CommandApprovalAnalyzer',
]
@@ -1,3 +0,0 @@
from openhands.security.command_approval.analyzer import CommandApprovalAnalyzer
__all__ = ['CommandApprovalAnalyzer']
@@ -1,305 +0,0 @@
"""Command approval analyzer for security."""
import re
from typing import Any
import bashlex
from fastapi import Request
from openhands.core.logger import openhands_logger as logger
from openhands.events.action.action import (
Action,
ActionConfirmationStatus,
ActionSecurityRisk,
)
from openhands.events.action.commands import CmdRunAction, IPythonRunCellAction
from openhands.events.event import Event
from openhands.events.stream import EventStream
from openhands.security.analyzer import SecurityAnalyzer
class CommandPattern:
"""A pattern for matching commands."""
def __init__(self, pattern: str, description: str):
"""Initialize a new command pattern.
Args:
pattern: The regex pattern to match commands against.
description: A human-readable description of what this pattern matches.
"""
self.pattern = pattern
self.description = description
self._compiled_pattern = re.compile(pattern)
def matches(self, command: str) -> bool:
"""Check if the command matches this pattern.
Args:
command: The command to check.
Returns:
bool: True if the command matches, False otherwise.
"""
return bool(self._compiled_pattern.match(command))
class CommandParser:
"""Parser for bash commands using bashlex."""
def is_piped_command(self, command: str) -> bool:
"""Check if a command contains pipes.
Args:
command: The command to check.
Returns:
bool: True if the command contains pipes, False otherwise.
"""
if not command or not command.strip():
return False
try:
parts = bashlex.parse(command)
for part in parts:
if part.kind == 'pipeline':
return True
return False
except Exception as e:
logger.warning(f'Error parsing command with bashlex: {e}')
# Fallback: check for pipe character not in quotes
# This is a simple heuristic and not as accurate as bashlex parsing
in_single_quote = False
in_double_quote = False
for char in command:
if char == "'" and not in_double_quote:
in_single_quote = not in_single_quote
elif char == '"' and not in_single_quote:
in_double_quote = not in_double_quote
elif char == '|' and not in_single_quote and not in_double_quote:
return True
return False
def parse_command(self, command: str) -> list[str]:
"""Parse a command into individual parts, handling pipes.
Args:
command: The command to parse.
Returns:
List[str]: List of individual commands.
"""
if not command or not command.strip():
return []
try:
parts = bashlex.parse(command)
commands = []
# Helper function to extract command from a node
def extract_command(node):
if node.kind == 'command':
cmd_parts = []
for part in node.parts:
if hasattr(part, 'word'):
cmd_parts.append(part.word)
if cmd_parts:
return ' '.join(cmd_parts)
return None
# Process the AST
for part in parts:
if part.kind == 'pipeline':
# A pipeline has multiple commands
for subpart in part.parts:
if subpart.kind == 'command':
cmd = extract_command(subpart)
if cmd:
commands.append(cmd)
elif part.kind == 'command':
# A single command
cmd = extract_command(part)
if cmd:
commands.append(cmd)
elif part.kind == 'list':
# A list of commands (e.g., with && or ||)
# We only take the first command for approval purposes
for subpart in part.parts:
if subpart.kind == 'command':
cmd = extract_command(subpart)
if cmd:
commands.append(cmd)
break
elif subpart.kind == 'operator':
# Stop at the first operator
break
return commands
except Exception as e:
logger.warning(f'Error parsing command with bashlex: {e}')
# Fallback: simple split by pipe
# This is a simple heuristic and not as accurate as bashlex parsing
if '|' in command:
return [part.strip() for part in command.split('|') if part.strip()]
else:
return [command.strip()] if command.strip() else []
class CommandApprovalAnalyzer(SecurityAnalyzer):
"""Security analyzer that automatically approves commands based on patterns and previously approved commands."""
def __init__(
self,
event_stream: EventStream,
policy: str | None = None,
sid: str | None = None,
) -> None:
"""Initializes a new instance of the CommandApprovalAnalyzer class."""
super().__init__(event_stream)
self.parser = CommandParser()
self.approved_commands: dict[
str, bool
] = {} # Dict of exact commands that have been approved
self.approved_patterns: list[
CommandPattern
] = [] # List of regex patterns for approved commands
self.compiled_patterns: dict[
str, re.Pattern
] = {} # Cache of compiled regex patterns
# Add some default patterns
self._add_default_patterns()
def _add_default_patterns(self) -> None:
"""Add default command patterns that are always approved."""
# Simple, safe commands
self.approved_patterns.append(
CommandPattern(
pattern=r'^ls(\s+-[a-zA-Z]+)*(\s+\S+)*$',
description='List directory contents',
)
)
self.approved_patterns.append(
CommandPattern(pattern=r'^cd(\s+\S+)?$', description='Change directory')
)
self.approved_patterns.append(
CommandPattern(pattern=r'^pwd$', description='Print working directory')
)
self.approved_patterns.append(
CommandPattern(pattern=r'^echo\s+.*$', description='Echo text')
)
def is_command_approved(self, command: str) -> bool:
"""Check if a command is approved and doesn't need confirmation.
Args:
command: The command to check.
Returns:
bool: True if the command is approved, False otherwise.
"""
if not command or not command.strip():
return False
# Check if this is a piped command
if self.parser.is_piped_command(command):
# For piped commands, all parts must be approved
sub_commands = self.parser.parse_command(command)
return all(self._is_single_command_approved(cmd) for cmd in sub_commands)
else:
# For single commands, just check directly
return self._is_single_command_approved(command)
def _is_single_command_approved(self, command: str) -> bool:
"""Check if a single (non-piped) command is approved.
Args:
command: The command to check.
Returns:
bool: True if the command is approved, False otherwise.
"""
command = command.strip()
# Check exact matches first
if command in self.approved_commands:
return self.approved_commands[command]
# Then check patterns from CommandPattern objects
for pattern in self.approved_patterns:
if pattern.matches(command):
return True
# Then check string patterns from the config
from openhands.core.config import load_openhands_config
try:
config = load_openhands_config()
if hasattr(config, 'security') and hasattr(
config.security, 'approved_command_patterns'
):
for pattern_str in config.security.approved_command_patterns:
# Compile the pattern if not already compiled
if pattern_str not in self.compiled_patterns:
try:
self.compiled_patterns[pattern_str] = re.compile(
pattern_str
)
except re.error:
# Skip invalid patterns
continue
# Check if the command matches the pattern
if self.compiled_patterns[pattern_str].match(command):
return True
except Exception:
# If there's any error loading the config, just continue without checking patterns
pass
return False
def approve_command(self, command: str) -> None:
"""Add a command to the approved commands list.
Args:
command: The command to approve.
"""
self.approved_commands[command] = True
# In a real implementation, we would save this to config.toml
logger.info(f"Command '{command}' approved for future use")
async def handle_api_request(self, request: Request) -> Any:
"""Handles the incoming API request."""
# This analyzer doesn't need to handle API requests
return {'message': "Command approval analyzer doesn't support API requests"}
async def security_risk(self, event: Action) -> ActionSecurityRisk:
"""Evaluates the Action for security risks and returns the risk level.
For command approval analyzer, we always return LOW risk level,
but we set the confirmation_state based on whether the command is approved.
"""
# Only process CmdRunAction and IPythonRunCellAction
if isinstance(event, CmdRunAction):
command = event.command
if self.is_command_approved(command):
event.confirmation_state = ActionConfirmationStatus.CONFIRMED
logger.info(f'Command automatically approved: {command}')
elif isinstance(event, IPythonRunCellAction):
code = event.code
if self.is_command_approved(code):
event.confirmation_state = ActionConfirmationStatus.CONFIRMED
logger.info(f'Python code automatically approved: {code}')
# Always return LOW risk level - we're not evaluating risk, just auto-approving
return ActionSecurityRisk.LOW
async def act(self, event: Event) -> None:
"""Performs an action based on the analyzed event.
This analyzer doesn't need to perform any actions since command approval
is handled directly in the CLI interface.
"""
pass
-2
View File
@@ -1,8 +1,6 @@
from openhands.security.analyzer import SecurityAnalyzer
from openhands.security.command_approval.analyzer import CommandApprovalAnalyzer
from openhands.security.invariant.analyzer import InvariantAnalyzer
SecurityAnalyzers: dict[str, type[SecurityAnalyzer]] = {
'invariant': InvariantAnalyzer,
'command_approval': CommandApprovalAnalyzer,
}
+3 -1
View File
@@ -4,6 +4,7 @@ from itertools import islice
from jinja2 import Template
from openhands.agenthub.codeact_agent.tools.bash import refine_prompt
from openhands.controller.state.state import State
from openhands.core.message import Message, TextContent
from openhands.events.observation.agent import MicroagentKnowledge
@@ -91,7 +92,8 @@ class PromptManager:
return Template(file.read())
def get_system_message(self) -> str:
return self.system_template.render().strip()
system_message = self.system_template.render().strip()
return refine_prompt(system_message)
def get_example_user_message(self) -> str:
"""This is an initial user message that can be provided to the agent
Generated
+20 -1
View File
@@ -5152,8 +5152,11 @@ files = [
{file = "lxml-5.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7ce1a171ec325192c6a636b64c94418e71a1964f56d002cc28122fceff0b6121"},
{file = "lxml-5.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:795f61bcaf8770e1b37eec24edf9771b307df3af74d1d6f27d812e15a9ff3872"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29f451a4b614a7b5b6c2e043d7b64a15bd8304d7e767055e8ab68387a8cacf4e"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:891f7f991a68d20c75cb13c5c9142b2a3f9eb161f1f12a9489c82172d1f133c0"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aa412a82e460571fad592d0f93ce9935a20090029ba08eca05c614f99b0cc92"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:ac7ba71f9561cd7d7b55e1ea5511543c0282e2b6450f122672a2694621d63b7e"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:c5d32f5284012deaccd37da1e2cd42f081feaa76981f0eaa474351b68df813c5"},
{file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:ce31158630a6ac85bddd6b830cffd46085ff90498b397bd0a259f59d27a12188"},
{file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:31e63621e073e04697c1b2d23fcb89991790eef370ec37ce4d5d469f40924ed6"},
{file = "lxml-5.4.0-cp37-cp37m-win32.whl", hash = "sha256:be2ba4c3c5b7900246a8f866580700ef0d538f2ca32535e991027bdaba944063"},
{file = "lxml-5.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:09846782b1ef650b321484ad429217f5154da4d6e786636c38e434fa32e94e49"},
@@ -5227,6 +5230,22 @@ files = [
[package.dependencies]
cobble = ">=0.1.3,<0.2"
[[package]]
name = "markdown"
version = "3.8.2"
description = "Python implementation of John Gruber's Markdown."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24"},
{file = "markdown-3.8.2.tar.gz", hash = "sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45"},
]
[package.extras]
docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"]
testing = ["coverage", "pyyaml"]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
@@ -11766,4 +11785,4 @@ third-party-runtimes = ["daytona", "e2b", "modal", "runloop-api-client"]
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "8568c6ec2e11d4fcb23e206a24896b4d2d50e694c04011b668148f484e95b406"
content-hash = "d83111cc28bf935f1c759d3ce07a21c69a85f6df035db26042326bd8fba4969f"
+1
View File
@@ -58,6 +58,7 @@ whatthepatch = "^1.0.6"
protobuf = "^5.0.0,<6.0.0" # Updated to support newer opentelemetry
opentelemetry-api = "^1.33.1"
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
markdown = "^3.6" # Required for CLI TUI rendering
libtmux = ">=0.37,<0.40"
pygithub = "^2.5.0"
-373
View File
@@ -1,373 +0,0 @@
"""Tests for command pattern generation and parsing."""
import re
from unittest.mock import MagicMock, patch
import pytest
from openhands.cli.tui import (
_generate_command_patterns,
_generate_single_command_pattern,
_handle_pattern_selection,
_parse_piped_command,
read_confirmation_input,
)
class TestGenerateSingleCommandPattern:
"""Test the _generate_single_command_pattern function."""
def test_empty_command(self):
"""Test pattern generation for empty command."""
patterns = _generate_single_command_pattern('')
assert len(patterns) == 1
assert patterns[0] == '^$'
def test_single_word_command(self):
"""Test pattern generation for single word command."""
patterns = _generate_single_command_pattern('ls')
assert len(patterns) == 1
assert patterns[0] == '^ls(\\s.*|$)'
def test_two_word_command(self):
"""Test pattern generation for two word command."""
patterns = _generate_single_command_pattern('ls -la')
assert len(patterns) == 2
assert patterns[0] == '^ls(\\s.*|$)'
assert patterns[1] == '^ls\\s+\\-la.*$'
def test_three_word_command(self):
"""Test pattern generation for three word command."""
patterns = _generate_single_command_pattern('git commit -m')
assert len(patterns) == 3
assert patterns[0] == '^git(\\s.*|$)'
assert patterns[1] == '^git\\s+commit.*$'
assert patterns[2] == '^git\\s+commit\\s+\\-m.*$'
def test_four_word_command(self):
"""Test pattern generation for four word command (should only generate 3 patterns)."""
patterns = _generate_single_command_pattern('git commit -m message')
assert len(patterns) == 3 # Only first 3 prefixes
assert patterns[0] == '^git(\\s.*|$)'
assert patterns[1] == '^git\\s+commit.*$'
assert patterns[2] == '^git\\s+commit\\s+\\-m.*$'
def test_pattern_matching(self):
"""Test that generated patterns actually match similar commands."""
patterns = _generate_single_command_pattern('ls -la')
# Test first pattern (ls.*)
pattern1 = re.compile(patterns[0])
assert pattern1.match('ls')
assert pattern1.match('ls -la')
assert pattern1.match('ls -alh /home')
assert not pattern1.match('cat file.txt')
# Test second pattern (ls -la.*)
pattern2 = re.compile(patterns[1])
assert pattern2.match('ls -la')
assert pattern2.match('ls -la /home')
assert not pattern2.match('ls')
assert not pattern2.match('ls -alh')
def test_special_characters_escaped(self):
"""Test that special regex characters are properly escaped."""
patterns = _generate_single_command_pattern('echo $HOME')
assert len(patterns) == 2
assert patterns[0] == '^echo(\\s.*|$)'
assert patterns[1] == '^echo\\s+\\$HOME.*$'
# Test that the pattern works
pattern = re.compile(patterns[1])
assert pattern.match('echo $HOME')
assert pattern.match('echo $HOME/test')
class TestParsePipedCommand:
"""Test the _parse_piped_command function."""
def test_empty_command(self):
"""Test parsing empty command."""
result = _parse_piped_command('')
assert result == []
def test_whitespace_only_command(self):
"""Test parsing whitespace-only command."""
result = _parse_piped_command(' ')
assert result == []
def test_single_command(self):
"""Test parsing single command without pipes."""
result = _parse_piped_command('ls -la')
assert result == ['ls -la']
def test_simple_piped_command(self):
"""Test parsing simple piped command."""
result = _parse_piped_command('ls -la | grep test')
assert result == ['ls -la', 'grep test']
def test_three_command_pipe(self):
"""Test parsing three-command pipe."""
result = _parse_piped_command('cat file.txt | grep pattern | wc -l')
assert result == ['cat file.txt', 'grep pattern', 'wc -l']
def test_pipe_with_quotes(self):
"""Test parsing piped command with quoted arguments."""
result = _parse_piped_command('echo "hello world" | grep "hello"')
# shlex removes quotes, so we get the unquoted content
assert result == ['echo hello world', 'grep hello']
def test_pipe_without_spaces(self):
"""Test parsing piped command without spaces around pipes."""
result = _parse_piped_command('ls|grep test')
assert result == ['ls', 'grep test']
def test_complex_command_with_options(self):
"""Test parsing complex command with various options."""
result = _parse_piped_command(
"find /home -name '*.py' | xargs grep -l 'import os'"
)
# shlex removes quotes, so we get the unquoted content
assert result == ['find /home -name *.py', 'xargs grep -l import os']
def test_invalid_quotes(self):
"""Test parsing command with invalid quotes falls back gracefully."""
result = _parse_piped_command('echo "unclosed quote | grep test')
assert result == ['echo "unclosed quote', 'grep test']
class TestGenerateCommandPatterns:
"""Test the _generate_command_patterns function."""
def test_single_command(self):
"""Test pattern generation for single command."""
patterns = _generate_command_patterns('ls -la')
assert len(patterns) == 2
assert patterns[0] == '^ls(\\s.*|$)'
assert patterns[1] == '^ls\\s+\\-la.*$'
def test_piped_command(self):
"""Test pattern generation for piped command."""
patterns = _generate_command_patterns('ls -la | grep test')
assert len(patterns) == 1
# Should combine the first pattern from each sub-command
assert patterns[0] == '^ls(\\s.*|$)\\s*\\|\\s*grep(\\s.*|$)$'
def test_empty_command(self):
"""Test pattern generation for empty command."""
patterns = _generate_command_patterns('')
assert len(patterns) == 1
assert patterns[0] == '^$'
class TestReadConfirmationInput:
"""Test the read_confirmation_input function."""
@pytest.mark.asyncio
@patch('openhands.cli.tui.cli_confirm')
async def test_yes_option(self, mock_confirm):
"""Test selecting 'yes' option."""
mock_confirm.return_value = 0 # First option (Yes, proceed)
config = MagicMock()
config.cli = MagicMock(vi_mode=False)
result = await read_confirmation_input(config=config, command='ls -la')
assert result == 'yes'
@pytest.mark.asyncio
@patch('openhands.cli.tui.cli_confirm')
async def test_no_option(self, mock_confirm):
"""Test selecting 'no' option."""
mock_confirm.return_value = 1 # Second option (No)
config = MagicMock()
config.cli = MagicMock(vi_mode=False)
result = await read_confirmation_input(config=config, command='ls -la')
assert result == 'no'
@pytest.mark.asyncio
@patch('openhands.cli.tui.cli_confirm')
async def test_always_option(self, mock_confirm):
"""Test selecting 'always' option."""
mock_confirm.return_value = 2 # Third option (Always proceed)
config = MagicMock()
config.cli = MagicMock(vi_mode=False)
result = await read_confirmation_input(config=config, command='ls -la')
assert result == 'always'
@pytest.mark.asyncio
@patch('openhands.cli.tui._handle_pattern_selection')
@patch('openhands.cli.tui.cli_confirm')
async def test_remember_option(self, mock_confirm, mock_pattern_selection):
"""Test selecting 'remember' option."""
mock_confirm.return_value = 3 # Fourth option (Remember)
mock_pattern_selection.return_value = (
'remember:^ls.*$:Commands starting with: ls'
)
config = MagicMock()
config.cli = MagicMock(vi_mode=False)
result = await read_confirmation_input(config=config, command='ls -la')
assert result == 'remember:^ls.*$:Commands starting with: ls'
mock_pattern_selection.assert_called_once_with(config, 'ls -la')
class TestHandlePatternSelection:
"""Test the _handle_pattern_selection function."""
@pytest.mark.asyncio
@patch('openhands.cli.tui.cli_confirm')
async def test_exact_command_selection(self, mock_confirm):
"""Test selecting exact command pattern."""
mock_confirm.return_value = 0 # First option (exact command)
config = MagicMock()
config.cli = MagicMock(vi_mode=False)
result = await _handle_pattern_selection(config, 'ls -la')
assert result.startswith('remember:^ls\\ \\-la$:Exact command: ls -la')
@pytest.mark.asyncio
@patch('openhands.cli.tui.cli_confirm')
async def test_prefix_pattern_selection(self, mock_confirm):
"""Test selecting prefix pattern."""
mock_confirm.return_value = 1 # Second option (first prefix pattern)
config = MagicMock()
config.cli = MagicMock(vi_mode=False)
result = await _handle_pattern_selection(config, 'ls -la')
assert result.startswith('remember:^ls(\\s.*|$):Commands starting with: ls')
@pytest.mark.asyncio
@patch('openhands.cli.tui.create_prompt_session')
@patch('openhands.cli.tui.cli_confirm')
async def test_custom_pattern_selection_valid(
self, mock_confirm, mock_create_session
):
"""Test selecting custom pattern with valid regex."""
mock_confirm.return_value = (
3 # Custom pattern option (assuming 3 total options)
)
# Mock the prompt session
mock_session = MagicMock()
# Create a proper async mock
async def mock_prompt_async(prompt):
return '^git.*$'
mock_session.prompt_async = mock_prompt_async
mock_create_session.return_value = mock_session
config = MagicMock()
config.cli = MagicMock(vi_mode=False)
result = await _handle_pattern_selection(config, 'ls -la')
assert result == 'remember:^git.*$:Custom pattern: ^git.*$'
@pytest.mark.asyncio
@patch('openhands.cli.tui.create_prompt_session')
@patch('openhands.cli.tui.cli_confirm')
@patch('openhands.cli.tui.print_formatted_text')
async def test_custom_pattern_selection_invalid(
self, mock_print, mock_confirm, mock_create_session
):
"""Test selecting custom pattern with invalid regex."""
mock_confirm.return_value = 3 # Custom pattern option
# Mock the prompt session to return invalid regex
mock_session = MagicMock()
# Create a proper async mock
async def mock_prompt_async(prompt):
return '[invalid regex'
mock_session.prompt_async = mock_prompt_async
mock_create_session.return_value = mock_session
config = MagicMock()
config.cli = MagicMock(vi_mode=False)
result = await _handle_pattern_selection(config, 'ls -la')
# Should fall back to exact command
assert result.startswith('remember:^ls\\ \\-la$:Exact command: ls -la')
# Should print error message
mock_print.assert_called()
class TestPatternMatching:
"""Test that the generated patterns work correctly for matching commands."""
def test_ls_patterns(self):
"""Test patterns generated for ls command."""
patterns = _generate_single_command_pattern('ls -alh')
# Test first pattern (ls.*)
pattern1 = re.compile(patterns[0])
assert pattern1.match('ls')
assert pattern1.match('ls -la')
assert pattern1.match('ls -alh /home')
assert pattern1.match('ls --help')
assert not pattern1.match('cat file.txt')
assert not pattern1.match('lsof') # Should not match partial word
# Test second pattern (ls -alh.*)
pattern2 = re.compile(patterns[1])
assert pattern2.match('ls -alh')
assert pattern2.match('ls -alh /home')
assert not pattern2.match('ls')
assert not pattern2.match('ls -la')
def test_git_patterns(self):
"""Test patterns generated for git command."""
patterns = _generate_single_command_pattern('git commit -m')
# Test git(\s.*|$)
pattern1 = re.compile(patterns[0])
assert pattern1.match('git status')
assert pattern1.match('git commit')
assert pattern1.match('git push origin main')
assert not pattern1.match('github') # Should not match partial word
# Test git commit.*
pattern2 = re.compile(patterns[1])
assert pattern2.match('git commit')
assert pattern2.match("git commit -m 'message'")
assert pattern2.match('git commit --amend')
assert not pattern2.match('git status')
assert not pattern2.match('git push')
def test_piped_command_patterns(self):
"""Test patterns generated for piped commands."""
patterns = _generate_command_patterns('cat file.txt | grep pattern')
pattern = re.compile(patterns[0])
assert pattern.match('cat file.txt | grep pattern')
assert pattern.match('cat another.txt | grep something')
assert pattern.match('cat /path/to/file | grep test')
assert not pattern.match('cat file.txt')
assert not pattern.match('grep pattern')
def test_complex_command_patterns(self):
"""Test patterns for complex commands with special characters."""
patterns = _generate_single_command_pattern("find /home -name '*.py'")
# Test find(\s.*|$)
pattern1 = re.compile(patterns[0])
assert pattern1.match("find /home -name '*.py'")
assert pattern1.match('find . -type f')
assert pattern1.match('find /usr/bin -executable')
assert not pattern1.match('finder') # Should not match partial word
# Test find /home.*
pattern2 = re.compile(patterns[1])
assert pattern2.match("find /home -name '*.py'")
assert pattern2.match('find /home -type d')
assert not pattern2.match("find . -name '*.py'")
assert not pattern2.match("find /usr -name '*.py'")
@@ -1,48 +0,0 @@
"""Tests for the security config pipe parsing functionality."""
from openhands.security.command_approval.analyzer import CommandParser
class TestBashlexParsing:
"""Test bashlex parsing functionality."""
def test_bashlex_pipe_detection(self):
"""Test detection of piped commands using bashlex."""
parser = CommandParser()
# Commands with pipes
assert parser.is_piped_command('ls -la | grep .py')
assert parser.is_piped_command('cat file.txt | head -10 | tail -5')
assert parser.is_piped_command("find . -name '*.py' | xargs grep 'import'")
# Commands without pipes
assert not parser.is_piped_command('ls -la')
assert not parser.is_piped_command("echo 'hello world'")
assert not parser.is_piped_command('ls -la > output.txt')
# Edge cases
assert not parser.is_piped_command('')
# Pipe in quotes is not a real pipe
assert not parser.is_piped_command("echo 'hello | world'")
def test_bashlex_command_extraction(self):
"""Test extraction of commands from pipelines using bashlex."""
parser = CommandParser()
# Simple command
assert parser.parse_command('ls -la') == ['ls -la']
# Piped commands
assert parser.parse_command('ls -la | grep .py') == ['ls -la', 'grep .py']
assert parser.parse_command('cat file.txt | head -10 | tail -5') == [
'cat file.txt',
'head -10',
'tail -5',
]
# Commands with redirections
assert parser.parse_command('ls -la > output.txt') == ['ls -la']
# Edge cases
assert parser.parse_command('') == []
assert parser.parse_command("echo 'hello | world'") == ['echo hello | world']
@@ -0,0 +1,179 @@
import sys
from unittest.mock import patch
import pytest
from openhands.agenthub.codeact_agent.codeact_agent import CodeActAgent
from openhands.core.config import AgentConfig
from openhands.llm.llm import LLM
# Skip all tests in this module if not running on Windows
pytestmark = pytest.mark.skipif(
sys.platform != 'win32', reason='Windows prompt refinement tests require Windows'
)
@pytest.fixture
def mock_llm():
"""Create a mock LLM for testing."""
llm = LLM(config={'model': 'gpt-4', 'api_key': 'test'})
return llm
@pytest.fixture
def agent_config():
"""Create a basic agent config for testing."""
return AgentConfig()
def test_codeact_agent_system_prompt_no_bash_on_windows(mock_llm, agent_config):
"""Test that CodeActAgent's system prompt doesn't contain 'bash' on Windows."""
# Create a CodeActAgent instance
agent = CodeActAgent(llm=mock_llm, config=agent_config)
# Get the system prompt
system_prompt = agent.prompt_manager.get_system_message()
# Assert that 'bash' doesn't exist in the system prompt (case-insensitive)
assert 'bash' not in system_prompt.lower(), (
f"System prompt contains 'bash' on Windows platform. "
f"It should be replaced with 'powershell'. "
f'System prompt: {system_prompt}'
)
# Verify that 'powershell' exists instead (case-insensitive)
assert 'powershell' in system_prompt.lower(), (
f"System prompt should contain 'powershell' on Windows platform. "
f'System prompt: {system_prompt}'
)
def test_codeact_agent_tool_descriptions_no_bash_on_windows(mock_llm, agent_config):
"""Test that CodeActAgent's tool descriptions don't contain 'bash' on Windows."""
# Create a CodeActAgent instance
agent = CodeActAgent(llm=mock_llm, config=agent_config)
# Get the tools
tools = agent.tools
# Check each tool's description and parameters
for tool in tools:
if tool['type'] == 'function':
function_info = tool['function']
# Check function description
description = function_info.get('description', '')
assert 'bash' not in description.lower(), (
f"Tool '{function_info['name']}' description contains 'bash' on Windows. "
f'Description: {description}'
)
# Check parameter descriptions
parameters = function_info.get('parameters', {})
properties = parameters.get('properties', {})
for param_name, param_info in properties.items():
param_description = param_info.get('description', '')
assert 'bash' not in param_description.lower(), (
f"Tool '{function_info['name']}' parameter '{param_name}' "
f"description contains 'bash' on Windows. "
f'Parameter description: {param_description}'
)
def test_in_context_learning_example_no_bash_on_windows():
"""Test that in-context learning examples don't contain 'bash' on Windows."""
from openhands.agenthub.codeact_agent.tools.bash import create_cmd_run_tool
from openhands.agenthub.codeact_agent.tools.finish import FinishTool
from openhands.agenthub.codeact_agent.tools.str_replace_editor import (
create_str_replace_editor_tool,
)
from openhands.llm.fn_call_converter import get_example_for_tools
# Create a sample set of tools
tools = [
create_cmd_run_tool(),
create_str_replace_editor_tool(),
FinishTool,
]
# Get the in-context learning example
example = get_example_for_tools(tools)
# Assert that 'bash' doesn't exist in the example (case-insensitive)
assert 'bash' not in example.lower(), (
f"In-context learning example contains 'bash' on Windows platform. "
f"It should be replaced with 'powershell'. "
f'Example: {example}'
)
# Verify that 'powershell' exists instead (case-insensitive)
if example: # Only check if example is not empty
assert 'powershell' in example.lower(), (
f"In-context learning example should contain 'powershell' on Windows platform. "
f'Example: {example}'
)
def test_refine_prompt_function_works():
"""Test that the refine_prompt function correctly replaces 'bash' with 'powershell'."""
from openhands.agenthub.codeact_agent.tools.bash import refine_prompt
# Test basic replacement
test_prompt = 'Execute a bash command to list files'
refined_prompt = refine_prompt(test_prompt)
assert 'bash' not in refined_prompt.lower()
assert 'powershell' in refined_prompt.lower()
assert refined_prompt == 'Execute a powershell command to list files'
# Test multiple occurrences
test_prompt = 'Use bash to run bash commands in the bash shell'
refined_prompt = refine_prompt(test_prompt)
assert 'bash' not in refined_prompt.lower()
assert (
refined_prompt
== 'Use powershell to run powershell commands in the powershell shell'
)
# Test case sensitivity
test_prompt = 'BASH and Bash and bash should all be replaced'
refined_prompt = refine_prompt(test_prompt)
assert 'bash' not in refined_prompt.lower()
assert (
refined_prompt
== 'powershell and powershell and powershell should all be replaced'
)
# Test execute_bash tool name replacement
test_prompt = 'Use the execute_bash tool to run commands'
refined_prompt = refine_prompt(test_prompt)
assert 'execute_bash' not in refined_prompt.lower()
assert 'execute_powershell' in refined_prompt.lower()
assert refined_prompt == 'Use the execute_powershell tool to run commands'
# Test that words containing 'bash' but not equal to 'bash' are preserved
test_prompt = 'The bashful person likes bash-like syntax'
refined_prompt = refine_prompt(test_prompt)
# 'bashful' should be preserved, 'bash-like' should become 'powershell-like'
assert 'bashful' in refined_prompt
assert 'powershell-like' in refined_prompt
assert refined_prompt == 'The bashful person likes powershell-like syntax'
def test_refine_prompt_function_on_non_windows():
"""Test that the refine_prompt function doesn't change anything on non-Windows platforms."""
from openhands.agenthub.codeact_agent.tools.bash import refine_prompt
# Mock sys.platform to simulate non-Windows
with patch('openhands.agenthub.codeact_agent.tools.bash.sys.platform', 'linux'):
test_prompt = 'Execute a bash command to list files'
refined_prompt = refine_prompt(test_prompt)
# On non-Windows, the prompt should remain unchanged
assert refined_prompt == test_prompt
assert 'bash' in refined_prompt.lower()