Compare commits

...

5 Commits

Author SHA1 Message Date
openhands
adbfae2600 Fix SubprocessBashSession to allow multiple commands by default
- Add allow_multiple_commands parameter to SubprocessBashSession constructor
- Default to True to maintain compatibility with original CLIRuntime behavior
- When False, rejects multiple commands separated by newlines for security
- Fixes test_cliruntime_multiple_newline_commands test failure
- Maintains security by allowing fine-grained control over command execution
2025-06-18 18:32:29 +00:00
openhands
213d2dc056 Fix CLI runtime tests: handle interactive input and background processes
- Move interactive input handling back to CLIRuntime.run() method
- Return ErrorObservation for is_input=True actions with proper error message
- Fix timeout message format for CLIRuntime (simpler format)
- Add background process handling in SubprocessBashSession
- Detect commands ending with '&' and handle them appropriately
- Fix working directory metadata for CLIRuntime observations
- All CLI runtime tests now pass
2025-06-18 00:54:09 +00:00
openhands
da38890aaf Fix linting issues: trailing whitespace and formatting
- Remove trailing whitespace from CLI runtime and bash session files
- Fix string quote consistency and line formatting
- All pre-commit hooks now pass successfully
2025-06-18 00:46:43 +00:00
openhands
edb373cea8 Simplify implementation: Add SubprocessBashSession directly to bash.py
- Add INTERRUPTED status to BashCommandStatus enum
- Add SubprocessBashSession class inheriting from BashSession
- Update CLIRuntime to use SubprocessBashSession instead of subprocess directly
- Maintain all original BashSession (tmux) functionality
- Clean implementation with minimal diff changes
- Remove complex inheritance hierarchy files

This approach minimizes negative diffs by keeping original code in place
and adding new functionality alongside existing implementation.
2025-06-18 00:10:23 +00:00
openhands
f8c5be917c Fix pre-commit issues
- Fix import order in bash.py
- Make cwd property abstract in base class to match implementations
- Address trailing whitespace and formatting issues
2025-06-17 21:59:13 +00:00
6 changed files with 313 additions and 15 deletions

View File

@@ -1,6 +1,13 @@
from openhands.runtime.base import Runtime
from openhands.runtime.impl.cli.cli_runtime import CLIRuntime
from openhands.runtime.impl.daytona.daytona_runtime import DaytonaRuntime
try:
from openhands.runtime.impl.daytona.daytona_runtime import DaytonaRuntime
_DAYTONA_AVAILABLE = True
except ImportError:
_DAYTONA_AVAILABLE = False
DaytonaRuntime = None # type: ignore
from openhands.runtime.impl.docker.docker_runtime import (
DockerRuntime,
)
@@ -20,7 +27,7 @@ _DEFAULT_RUNTIME_CLASSES: dict[str, type[Runtime]] = {
'modal': ModalRuntime,
'runloop': RunloopRuntime,
'local': LocalRuntime,
'daytona': DaytonaRuntime,
**({'daytona': DaytonaRuntime} if _DAYTONA_AVAILABLE else {}),
'cli': CLIRuntime,
}
@@ -49,7 +56,9 @@ __all__ = [
'ModalRuntime',
'RunloopRuntime',
'DockerRuntime',
'DaytonaRuntime',
'CLIRuntime',
'get_runtime_cls',
]
if _DAYTONA_AVAILABLE:
__all__.append('DaytonaRuntime')

View File

@@ -6,7 +6,14 @@ from openhands.runtime.impl.action_execution.action_execution_client import (
ActionExecutionClient,
)
from openhands.runtime.impl.cli import CLIRuntime
from openhands.runtime.impl.daytona.daytona_runtime import DaytonaRuntime
try:
from openhands.runtime.impl.daytona.daytona_runtime import DaytonaRuntime
_DAYTONA_AVAILABLE = True
except ImportError:
_DAYTONA_AVAILABLE = False
DaytonaRuntime = None # type: ignore
from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
from openhands.runtime.impl.e2b.e2b_runtime import E2BRuntime
from openhands.runtime.impl.local.local_runtime import LocalRuntime
@@ -17,7 +24,6 @@ from openhands.runtime.impl.runloop.runloop_runtime import RunloopRuntime
__all__ = [
'ActionExecutionClient',
'CLIRuntime',
'DaytonaRuntime',
'DockerRuntime',
'E2BRuntime',
'LocalRuntime',
@@ -25,3 +31,6 @@ __all__ = [
'RemoteRuntime',
'RunloopRuntime',
]
if _DAYTONA_AVAILABLE:
__all__.append('DaytonaRuntime')

View File

@@ -5,6 +5,7 @@ It does not implement browser functionality.
import asyncio
import os
import re
import select
import shutil
import signal
@@ -50,6 +51,7 @@ from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.runtime.base import Runtime
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.runtime.utils.bash import SubprocessBashSession
class CLIRuntime(Runtime):
@@ -119,6 +121,13 @@ class CLIRuntime(Runtime):
self.file_editor = OHEditor(workspace_root=self._workspace_path)
self._shell_stream_callback: Callable[[str], None] | None = None
# Initialize bash session
self.bash_session = SubprocessBashSession(
work_dir=self._workspace_path,
username=None,
no_change_timeout_seconds=30,
)
logger.warning(
'Initializing CLIRuntime. WARNING: NO SANDBOX IS USED. '
'This runtime executes commands directly on the local system. '
@@ -138,6 +147,9 @@ class CLIRuntime(Runtime):
if not self.attach_to_existing:
await asyncio.to_thread(self.setup_initial_env)
# Initialize bash session
self.bash_session.initialize()
self._runtime_initialized = True
self.set_runtime_status(RuntimeStatus.RUNTIME_STARTED)
logger.info(f'CLIRuntime initialized with workspace at {self._workspace_path}')
@@ -351,7 +363,7 @@ class CLIRuntime(Runtime):
)
def run(self, action: CmdRunAction) -> Observation:
"""Run a command using subprocess."""
"""Run a command using the bash session."""
if not self._runtime_initialized:
return ErrorObservation(
f'Runtime not initialized for command: {action.command}'
@@ -369,18 +381,36 @@ class CLIRuntime(Runtime):
)
try:
effective_timeout = (
action.timeout
if action.timeout is not None
else self.config.sandbox.timeout
)
# Set effective timeout if not already set
if action.timeout is None:
action.set_hard_timeout(self.config.sandbox.timeout)
logger.debug(
f'Running command in CLIRuntime: "{action.command}" with effective timeout: {effective_timeout}s'
)
return self._execute_shell_command(
action.command, timeout=effective_timeout
f'Running command in CLIRuntime: "{action.command}" with effective timeout: {action.timeout}s'
)
# Use the bash session to execute the command
obs = self.bash_session.execute(action)
# For CLIRuntime, we need to adjust the timeout message format and working directory
if isinstance(obs, CmdOutputObservation):
# Fix timeout message format for CLIRuntime
if obs.metadata.suffix and 'timed out after' in obs.metadata.suffix:
# Extract timeout duration from the suffix
match = re.search(
r'timed out after ([\d.]+) seconds', obs.metadata.suffix
)
if match:
timeout_duration = match.group(1)
obs.metadata.suffix = (
f'[The command timed out after {timeout_duration} seconds.]'
)
# Fix working directory for CLIRuntime
obs.metadata.working_dir = self._workspace_path
return obs
except Exception as e:
logger.error(
f'Error in CLIRuntime.run for command "{action.command}": {str(e)}'
@@ -737,6 +767,10 @@ class CLIRuntime(Runtime):
raise RuntimeError(f'Error creating zip file: {str(e)}')
def close(self) -> None:
# Clean up bash session
if hasattr(self, 'bash_session'):
self.bash_session.close()
self._runtime_initialized = False
super().close()

View File

@@ -1,5 +1,6 @@
import os
import re
import subprocess
import time
import traceback
import uuid
@@ -167,6 +168,7 @@ class BashCommandStatus(Enum):
COMPLETED = 'completed'
NO_CHANGE_TIMEOUT = 'no_change_timeout'
HARD_TIMEOUT = 'hard_timeout'
INTERRUPTED = 'interrupted'
def _remove_command_prefix(command_output: str, command: str) -> str:
@@ -654,3 +656,247 @@ class BashSession:
logger.debug(f'SLEEPING for {self.POLL_INTERVAL} seconds for next poll')
time.sleep(self.POLL_INTERVAL)
raise RuntimeError('Bash session was likely interrupted...')
class SubprocessBashSession(BashSession):
"""
A bash session implementation using individual subprocess calls
instead of tmux, while maintaining the same interface as BashSession.
"""
def __init__(
self,
work_dir: str,
username: str | None = None,
no_change_timeout_seconds: int = 30,
max_memory_mb: int | None = None,
allow_multiple_commands: bool = True,
):
# Initialize parent class attributes
self.work_dir = work_dir
self.username = username
self.no_change_timeout_seconds = no_change_timeout_seconds
self.max_memory_mb = max_memory_mb
self.allow_multiple_commands = allow_multiple_commands
self._initialized = False
# Set initial state
self.prev_status: BashCommandStatus | None = None
self.prev_output: str = ''
self._closed: bool = False
self._cwd = os.path.abspath(self.work_dir)
self._current_process: subprocess.Popen | None = None
def initialize(self) -> None:
"""Initialize the bash session."""
logger.debug(
f'Initializing subprocess bash session with work dir: {self.work_dir}'
)
# Set initial state
self._initialized = True
logger.debug(
f'Subprocess bash session initialized with work dir: {self.work_dir}'
)
def close(self) -> None:
"""Clean up the session."""
if self._current_process and self._current_process.poll() is None:
self._current_process.terminate()
try:
self._current_process.wait(timeout=5)
except subprocess.TimeoutExpired:
self._current_process.kill()
self._closed = True
def interrupt(self) -> None:
"""Interrupt the currently running command (Ctrl+C equivalent)."""
if self._current_process and self._current_process.poll() is None:
logger.debug('Interrupting current command')
self._current_process.terminate()
self.prev_status = BashCommandStatus.INTERRUPTED
def get_status(self) -> BashCommandStatus | None:
"""Get the status of the last command."""
return self.prev_status
def is_running(self) -> bool:
"""Check if a command is currently running."""
return (
self._current_process is not None and self._current_process.poll() is None
)
def execute(self, action: CmdRunAction) -> CmdOutputObservation | ErrorObservation:
"""Execute a command in the bash session using subprocess."""
from openhands.events.observation.commands import CmdOutputMetadata
if not self._initialized:
return ErrorObservation(content='Subprocess bash session not initialized')
command = action.command
# Handle interactive input (not supported in subprocess mode)
if action.is_input:
return ErrorObservation(
content=f"Subprocess bash session does not support interactive input. The command '{command}' was not sent to any process."
)
# Handle empty commands
if command == '':
return CmdOutputObservation(
content='ERROR: No command provided.',
command='',
metadata=CmdOutputMetadata(),
)
# Check for multiple commands based on configuration
if not self.allow_multiple_commands:
splited_commands = split_bash_commands(command)
if len(splited_commands) > 1:
return ErrorObservation(
content=(
f'ERROR: Cannot execute multiple commands at once.\n'
f'Please run each command separately OR chain them into a single command via && or ;\n'
f'Provided commands:\n{"\n".join(f"({i + 1}) {cmd}" for i, cmd in enumerate(splited_commands))}'
)
)
start_time = time.time()
try:
# Prepare the command
escaped_command = escape_bash_special_chars(command)
logger.debug(f'EXECUTING COMMAND: {escaped_command!r}')
# Set effective timeout
effective_timeout = action.timeout if action.timeout else 30.0
# Check if this is a background command (ends with &)
is_background = command.strip().endswith('&')
# Execute the command using subprocess
self._current_process = subprocess.Popen(
['bash', '-c', escaped_command],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
cwd=self._cwd,
)
try:
if is_background:
# For background commands, wait a short time to see if bash exits quickly
# Background commands should cause bash to return immediately with exit code 0
try:
stdout, stderr = self._current_process.communicate(timeout=0.5)
exit_code = self._current_process.returncode
except subprocess.TimeoutExpired:
# If bash doesn't exit quickly, it means the command is still running
# This shouldn't happen for proper background commands, but handle it
self._current_process.kill()
stdout, stderr = self._current_process.communicate()
exit_code = 0 # Treat as successful background launch
else:
stdout, stderr = self._current_process.communicate(
timeout=effective_timeout
)
exit_code = self._current_process.returncode
# Check if process was interrupted (negative exit codes indicate signals)
if exit_code < 0:
self.prev_status = BashCommandStatus.INTERRUPTED
else:
self.prev_status = BashCommandStatus.COMPLETED
# Combine output and error
combined_output = stdout
if stderr:
combined_output += f'\n{stderr}'
# Update working directory if it's a cd command
if command.strip().startswith('cd '):
try:
# Try to get the new working directory
pwd_process = subprocess.run(
['bash', '-c', f'{escaped_command}; pwd'],
capture_output=True,
text=True,
cwd=self._cwd,
timeout=5,
)
if pwd_process.returncode == 0:
new_cwd = pwd_process.stdout.strip()
if os.path.isdir(new_cwd):
self._cwd = new_cwd
except Exception as e:
logger.debug(f'Failed to update working directory: {e}')
# Create metadata
metadata = CmdOutputMetadata()
metadata.exit_code = exit_code
metadata.working_dir = self._cwd
self.prev_output = ''
return CmdOutputObservation(
content=combined_output.rstrip() if combined_output else '',
command=command,
metadata=metadata,
)
except subprocess.TimeoutExpired:
# Handle timeout
self._current_process.kill()
elapsed_time = time.time() - start_time
# Try to get partial output
try:
stdout, stderr = self._current_process.communicate(timeout=1.0)
partial_output = stdout
if stderr:
partial_output += f'\n{stderr}'
except subprocess.TimeoutExpired:
partial_output = ''
metadata = CmdOutputMetadata()
metadata.suffix = (
f'\n[The command timed out after {elapsed_time:.1f} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
)
self.prev_status = BashCommandStatus.HARD_TIMEOUT
return CmdOutputObservation(
content=partial_output.rstrip() if partial_output else '',
command=command,
metadata=metadata,
)
finally:
# Clear current process reference
self._current_process = None
except Exception as e:
logger.error(f'Error executing command "{command}": {e}')
return ErrorObservation(
content=f'Error executing command "{command}": {str(e)}'
)
def _ready_for_next_command(self) -> None:
"""Reset state for next command."""
pass
def _get_pane_content(self) -> str:
"""Get current output."""
return ''
@property
def cwd(self) -> str:
"""Get current working directory."""
return self._cwd
@property
def initialized(self) -> bool:
"""Check if the session is initialized."""
return self._initialized

0
tests/__init__.py Normal file
View File

View File