From 349e2dbe50825708d1c322cca91e9dcce9fa77d6 Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Fri, 25 Oct 2024 07:44:25 -0500 Subject: [PATCH] refactor: move bash related logic into `BashSession` for cleaner code (#4527) Co-authored-by: Tim O'Farrell --- openhands/runtime/action_execution_server.py | 394 ++----------------- openhands/runtime/utils/__init__.py | 3 +- openhands/runtime/utils/bash.py | 280 +++++++++++++ openhands/runtime/utils/runtime_init.py | 103 +++++ 4 files changed, 422 insertions(+), 358 deletions(-) create mode 100644 openhands/runtime/utils/runtime_init.py diff --git a/openhands/runtime/action_execution_server.py b/openhands/runtime/action_execution_server.py index b81b5d4747..2da1372532 100644 --- a/openhands/runtime/action_execution_server.py +++ b/openhands/runtime/action_execution_server.py @@ -9,16 +9,13 @@ import argparse import asyncio import io import os -import re import shutil -import subprocess import tempfile import time from contextlib import asynccontextmanager from pathlib import Path from zipfile import ZipFile -import pexpect from fastapi import Depends, FastAPI, HTTPException, Request, UploadFile from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse, StreamingResponse @@ -37,10 +34,10 @@ from openhands.events.action import ( FileWriteAction, IPythonRunCellAction, ) -from openhands.events.event import EventSource from openhands.events.observation import ( CmdOutputObservation, ErrorObservation, + FatalErrorObservation, FileReadObservation, FileWriteObservation, IPythonRunCellObservation, @@ -54,8 +51,9 @@ from openhands.runtime.plugins import ( JupyterPlugin, Plugin, ) -from openhands.runtime.utils import split_bash_commands +from openhands.runtime.utils.bash import BashSession from openhands.runtime.utils.files import insert_lines, read_lines +from openhands.runtime.utils.runtime_init import init_user_and_working_directory from openhands.utils.async_utils import wait_all @@ -67,7 +65,6 @@ ROOT_GID = 0 INIT_COMMANDS = [ 'git config --global user.name "openhands" && git config --global user.email "openhands@all-hands.dev" && alias git="git --no-pager"', ] -SOFT_TIMEOUT_SECONDS = 5 SESSION_API_KEY = os.environ.get('SESSION_API_KEY') api_key_header = APIKeyHeader(name='X-Session-API-Key', auto_error=False) @@ -93,12 +90,20 @@ class ActionExecutor: browsergym_eval_env: str | None, ) -> None: self.plugins_to_load = plugins_to_load + self._initial_pwd = work_dir self.username = username self.user_id = user_id - self.pwd = work_dir # current PWD - self._initial_pwd = work_dir - self._init_user(self.username, self.user_id) - self._init_bash_shell(self.pwd, self.username) + _updated_user_id = init_user_and_working_directory( + username=username, user_id=self.user_id, initial_pwd=work_dir + ) + if _updated_user_id is not None: + self.user_id = _updated_user_id + + self.bash_session = BashSession( + work_dir=work_dir, + username=username, + ) + self.lock = asyncio.Lock() self.plugins: dict[str, Plugin] = {} self.browser = BrowserEnv(browsergym_eval_env) @@ -133,129 +138,10 @@ class ActionExecutor: if isinstance(plugin, JupyterPlugin): await self.run_ipython( - IPythonRunCellAction(code=f'import os; os.chdir("{self.pwd}")') - ) - - def _init_user(self, username: str, user_id: int) -> None: - """Create working directory and user if not exists. - It performs the following steps effectively: - * Creates the Working Directory: - - Uses mkdir -p to create the directory. - - Sets ownership to username:root. - - Adjusts permissions to be readable and writable by group and others. - * User Verification and Creation: - - Checks if the user exists using id -u. - - If the user exists with the correct UID, it skips creation. - - If the UID differs, it logs a warning and updates self.user_id. - - If the user doesn't exist, it proceeds to create the user. - * Sudo Configuration: - - Appends %sudo ALL=(ALL) NOPASSWD:ALL to /etc/sudoers to grant - passwordless sudo access to the sudo group. - - Adds the user to the sudo group with the useradd command, handling - UID conflicts by incrementing the UID if necessary. - """ - - # First create the working directory, independent of the user - logger.info(f'Client working directory: {self.initial_pwd}') - command = f'umask 002; mkdir -p {self.initial_pwd}' - output = subprocess.run(command, shell=True, capture_output=True) - out_str = output.stdout.decode() - - command = f'chown -R {username}:root {self.initial_pwd}' - output = subprocess.run(command, shell=True, capture_output=True) - out_str += output.stdout.decode() - - command = f'chmod g+rw {self.initial_pwd}' - output = subprocess.run(command, shell=True, capture_output=True) - out_str += output.stdout.decode() - logger.debug(f'Created working directory. Output: [{out_str}]') - - # Skip root since it is already created - if username == 'root': - return - - # Check if the username already exists - existing_user_id = -1 - try: - result = subprocess.run( - f'id -u {username}', shell=True, check=True, capture_output=True - ) - existing_user_id = int(result.stdout.decode().strip()) - - # The user ID already exists, skip setup - if existing_user_id == user_id: - logger.debug( - f'User `{username}` already has the provided UID {user_id}. Skipping user setup.' + IPythonRunCellAction( + code=f'import os; os.chdir("{self.bash_session.pwd}")' ) - else: - logger.warning( - f'User `{username}` already exists with UID {existing_user_id}. Skipping user setup.' - ) - self.user_id = existing_user_id - return - except subprocess.CalledProcessError as e: - # Returncode 1 indicates, that the user does not exist yet - if e.returncode == 1: - logger.debug( - f'User `{username}` does not exist. Proceeding with user creation.' - ) - else: - logger.error( - f'Error checking user `{username}`, skipping setup:\n{e}\n' - ) - raise - - # Add sudoer - sudoer_line = r"echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers" - output = subprocess.run(sudoer_line, shell=True, capture_output=True) - if output.returncode != 0: - raise RuntimeError(f'Failed to add sudoer: {output.stderr.decode()}') - logger.debug(f'Added sudoer successfully. Output: [{output.stdout.decode()}]') - - command = ( - f'useradd -rm -d /home/{username} -s /bin/bash ' - f'-g root -G sudo -u {user_id} {username}' - ) - output = subprocess.run(command, shell=True, capture_output=True) - if output.returncode == 0: - logger.debug( - f'Added user `{username}` successfully with UID {user_id}. Output: [{output.stdout.decode()}]' ) - else: - raise RuntimeError( - f'Failed to create user `{username}` with UID {user_id}. Output: [{output.stderr.decode()}]' - ) - - def _init_bash_shell(self, work_dir: str, username: str) -> None: - self.shell = pexpect.spawn( - f'su {username}', - encoding='utf-8', - codec_errors='replace', - echo=False, - ) - self.__bash_PS1 = ( - r'[PEXPECT_BEGIN]\n' - r'$(which python >/dev/null 2>&1 && echo "[Python Interpreter: $(which python)]\n")' - r'\u@\h:\w\n' - r'[PEXPECT_END]' - ) - - # This should NOT match "PS1=\u@\h:\w [PEXPECT]$" when `env` is executed - self.__bash_expect_regex = r'\[PEXPECT_BEGIN\]\s*(.*?)\s*([a-z0-9_-]*)@([a-zA-Z0-9.-]*):(.+)\s*\[PEXPECT_END\]' - # Set umask to allow group write permissions - self.shell.sendline(f'umask 002; export PS1="{self.__bash_PS1}"; export PS2=""') - self.shell.expect(self.__bash_expect_regex) - - self.shell.sendline( - f'if [ ! -d "{work_dir}" ]; then mkdir -p "{work_dir}"; fi && cd "{work_dir}"' - ) - self.shell.expect(self.__bash_expect_regex) - logger.debug( - f'Bash initialized. Working directory: {work_dir}. Output: [{self.shell.before}]' - ) - # Ensure the group has write permissions on the working directory - self.shell.sendline(f'chmod g+rw "{work_dir}"') - self.shell.expect(self.__bash_expect_regex) async def _init_bash_commands(self): logger.info(f'Initializing by running {len(INIT_COMMANDS)} bash commands...') @@ -263,7 +149,8 @@ class ActionExecutor: action = CmdRunAction(command=command) action.timeout = 300 logger.debug(f'Executing init command: {command}') - obs: CmdOutputObservation = await self.run(action) + obs = await self.run(action) + assert isinstance(obs, CmdOutputObservation) logger.debug( f'Init command outputs (exit code: {obs.exit_code}): {obs.content}' ) @@ -271,156 +158,6 @@ class ActionExecutor: logger.info('Bash init commands completed') - def _get_bash_prompt_and_update_pwd(self): - ps1 = self.shell.after - if ps1 == pexpect.EOF: - logger.error(f'Bash shell EOF! {self.shell.after=}, {self.shell.before=}') - raise RuntimeError('Bash shell EOF') - if ps1 == pexpect.TIMEOUT: - logger.warning('Bash shell timeout') - return '' - - # begin at the last occurrence of '[PEXPECT_BEGIN]'. - # In multi-line bash commands, the prompt will be repeated - # and the matched regex captures all of them - # - we only want the last one (newest prompt) - _begin_pos = ps1.rfind('[PEXPECT_BEGIN]') - if _begin_pos != -1: - ps1 = ps1[_begin_pos:] - - # parse the ps1 to get username, hostname, and working directory - matched = re.match(self.__bash_expect_regex, ps1) - assert ( - matched is not None - ), f'Failed to parse bash prompt: {ps1}. This should not happen.' - other_info, username, hostname, working_dir = matched.groups() - working_dir = working_dir.rstrip() - self.pwd = os.path.expanduser(working_dir) - - # re-assemble the prompt - # ignore the hostname AND use 'openhands-workspace' - prompt = f'{other_info.strip()}\n{username}@openhands-workspace:{working_dir} ' - if username == 'root': - prompt += '#' - else: - prompt += '$' - return prompt + ' ' - - def _execute_bash( - self, - command: str, - timeout: int, - keep_prompt: bool = True, - kill_on_timeout: bool = True, - ) -> tuple[str, int]: - logger.debug(f'Executing command: {command}') - self.shell.sendline(command) - return self._continue_bash( - timeout=timeout, keep_prompt=keep_prompt, kill_on_timeout=kill_on_timeout - ) - - def _interrupt_bash( - self, - action_timeout: int | None, - interrupt_timeout: int | None = None, - max_retries: int = 2, - ) -> tuple[str, int]: - interrupt_timeout = interrupt_timeout or 1 # default timeout for SIGINT - # try to interrupt the bash shell use SIGINT - while max_retries > 0: - self.shell.sendintr() # send SIGINT to the shell - logger.debug('Sent SIGINT to bash. Waiting for output...') - try: - self.shell.expect(self.__bash_expect_regex, timeout=interrupt_timeout) - output = self.shell.before - logger.debug(f'Received output after SIGINT: {output}') - exit_code = 130 # SIGINT - - _additional_msg = '' - if action_timeout is not None: - _additional_msg = ( - f'Command timed out after {action_timeout} seconds. ' - ) - output += ( - '\r\n\r\n' - + f'[{_additional_msg}SIGINT was sent to interrupt the command.]' - ) - return output, exit_code - except pexpect.TIMEOUT as e: - logger.warning(f'Bash pexpect.TIMEOUT while waiting for SIGINT: {e}') - max_retries -= 1 - - # fall back to send control-z - logger.error( - 'Failed to get output after SIGINT. Max retries reached. Sending control-z...' - ) - self.shell.sendcontrol('z') - self.shell.expect(self.__bash_expect_regex) - output = self.shell.before - logger.debug(f'Received output after control-z: {output}') - # Try to kill the job - self.shell.sendline('kill -9 %1') - self.shell.expect(self.__bash_expect_regex) - logger.debug(f'Received output after killing job %1: {self.shell.before}') - output += self.shell.before - - _additional_msg = '' - if action_timeout is not None: - _additional_msg = f'Command timed out after {action_timeout} seconds. ' - output += ( - '\r\n\r\n' - + f'[{_additional_msg}SIGINT was sent to interrupt the command, but failed. The command was killed.]' - ) - - # Try to get the exit code again - self.shell.sendline('echo $?') - self.shell.expect(self.__bash_expect_regex) - _exit_code_output = self.shell.before - exit_code = self._parse_exit_code(_exit_code_output) - - return output, exit_code - - def _parse_exit_code(self, output: str) -> int: - try: - exit_code = int(output.strip().split()[0]) - except Exception: - logger.error('Error getting exit code from bash script') - # If we try to run an invalid shell script the output sometimes includes error text - # rather than the error code - we assume this is an error - exit_code = 2 - return exit_code - - def _continue_bash( - self, - timeout: int, - keep_prompt: bool = True, - kill_on_timeout: bool = True, - ) -> tuple[str, int]: - logger.debug(f'Continuing bash with timeout={timeout}') - try: - self.shell.expect(self.__bash_expect_regex, timeout=timeout) - - output = self.shell.before - - # Get exit code - self.shell.sendline('echo $?') - logger.debug('Requesting exit code...') - self.shell.expect(self.__bash_expect_regex, timeout=timeout) - _exit_code_output = self.shell.before - exit_code = self._parse_exit_code(_exit_code_output) - except pexpect.TIMEOUT as e: - logger.warning(f'Bash pexpect.TIMEOUT while executing bash command: {e}') - if kill_on_timeout: - output, exit_code = self._interrupt_bash(action_timeout=timeout) - else: - output = self.shell.before or '' - exit_code = -1 - finally: - bash_prompt = self._get_bash_prompt_and_update_pwd() - if keep_prompt: - output += '\r\n' + bash_prompt - return output, exit_code - async def run_action(self, action) -> Observation: action_type = action.action logger.debug(f'Running action:\n{action}') @@ -428,62 +165,10 @@ class ActionExecutor: logger.debug(f'Action output:\n{observation}') return observation - async def run(self, action: CmdRunAction) -> CmdOutputObservation: - try: - assert ( - action.timeout is not None - ), f'Timeout argument is required for CmdRunAction: {action}' - commands = split_bash_commands(action.command) - all_output = '' - python_interpreter = '' - for command in commands: - if command == '': - output, exit_code = self._continue_bash( - timeout=SOFT_TIMEOUT_SECONDS, - keep_prompt=action.keep_prompt, - kill_on_timeout=False, - ) - elif command.lower() == 'ctrl+c': - output, exit_code = self._interrupt_bash( - action_timeout=None, # intentionally None - ) - else: - output, exit_code = self._execute_bash( - command, - timeout=SOFT_TIMEOUT_SECONDS - if not action.blocking - else action.timeout, - keep_prompt=action.keep_prompt, - kill_on_timeout=False if not action.blocking else True, - ) - # Get rid of the python interpreter string from each line of the output. - # We need it only once at the end. - parts = output.rsplit('[Python Interpreter: ', 1) - output = parts[0] - if len(parts) == 2: - python_interpreter = '[Python Interpreter: ' + parts[1] - if all_output: - # previous output already exists so we add a newline - all_output += '\r\n' - - # If the command originated with the agent, append the command that was run... - if action.source == EventSource.AGENT: - all_output += command + '\r\n' - - all_output += str(output) - if exit_code != 0: - break - - return CmdOutputObservation( - command_id=-1, - content=all_output.rstrip('\r\n'), - command=action.command, - hidden=action.hidden, - exit_code=exit_code, - interpreter_details=python_interpreter, - ) - except UnicodeDecodeError: - raise RuntimeError('Command output could not be decoded as utf-8') + async def run( + self, action: CmdRunAction + ) -> CmdOutputObservation | FatalErrorObservation: + return self.bash_session.run(action) async def run_ipython(self, action: IPythonRunCellAction) -> Observation: if 'jupyter' in self.plugins: @@ -491,21 +176,27 @@ class ActionExecutor: # This is used to make AgentSkills in Jupyter aware of the # current working directory in Bash jupyter_pwd = getattr(self, '_jupyter_pwd', None) - if self.pwd != jupyter_pwd: - logger.debug(f'{self.pwd} != {jupyter_pwd} -> reset Jupyter PWD') - reset_jupyter_pwd_code = f'import os; os.chdir("{self.pwd}")' + if self.bash_session.pwd != jupyter_pwd: + logger.debug( + f'{self.bash_session.pwd} != {jupyter_pwd} -> reset Jupyter PWD' + ) + reset_jupyter_pwd_code = ( + f'import os; os.chdir("{self.bash_session.pwd}")' + ) _aux_action = IPythonRunCellAction(code=reset_jupyter_pwd_code) _reset_obs: IPythonRunCellObservation = await _jupyter_plugin.run( _aux_action ) logger.debug( - f'Changed working directory in IPython to: {self.pwd}. Output: {_reset_obs}' + f'Changed working directory in IPython to: {self.bash_session.pwd}. Output: {_reset_obs}' ) - self._jupyter_pwd = self.pwd + self._jupyter_pwd = self.bash_session.pwd obs: IPythonRunCellObservation = await _jupyter_plugin.run(action) obs.content = obs.content.rstrip() - obs.content += f'\n[Jupyter current working directory: {self.pwd}]' + obs.content += ( + f'\n[Jupyter current working directory: {self.bash_session.pwd}]' + ) obs.content += f'\n[Jupyter Python interpreter: {_jupyter_plugin.python_interpreter_path}]' return obs else: @@ -513,15 +204,6 @@ class ActionExecutor: 'JupyterRequirement not found. Unable to run IPython action.' ) - def _get_working_directory(self): - # NOTE: this is part of initialization, so we hard code the timeout - result, exit_code = self._execute_bash('pwd', timeout=60, keep_prompt=False) - if exit_code != 0: - raise RuntimeError( - f'Failed to get working directory (exit code: {exit_code}): {result}' - ) - return result.strip() - def _resolve_path(self, path: str, working_dir: str) -> str: filepath = Path(path) if not filepath.is_absolute(): @@ -531,7 +213,7 @@ class ActionExecutor: async def read(self, action: FileReadAction) -> Observation: # NOTE: the client code is running inside the sandbox, # so there's no need to check permission - working_dir = self._get_working_directory() + working_dir = self.bash_session.workdir filepath = self._resolve_path(action.path, working_dir) try: with open(filepath, 'r', encoding='utf-8') as file: @@ -551,7 +233,7 @@ class ActionExecutor: return FileReadObservation(path=filepath, content=code_view) async def write(self, action: FileWriteAction) -> Observation: - working_dir = self._get_working_directory() + working_dir = self.bash_session.workdir filepath = self._resolve_path(action.path, working_dir) insert = action.content.split('\n') @@ -612,7 +294,7 @@ class ActionExecutor: return await browse(action, self.browser) def close(self): - self.shell.close() + self.bash_session.close() self.browser.close() diff --git a/openhands/runtime/utils/__init__.py b/openhands/runtime/utils/__init__.py index 71d9d4198c..622a0b6095 100644 --- a/openhands/runtime/utils/__init__.py +++ b/openhands/runtime/utils/__init__.py @@ -1,7 +1,6 @@ -from openhands.runtime.utils.bash import split_bash_commands from openhands.runtime.utils.system import ( display_number_matrix, find_available_tcp_port, ) -__all__ = ['display_number_matrix', 'find_available_tcp_port', 'split_bash_commands'] +__all__ = ['display_number_matrix', 'find_available_tcp_port'] diff --git a/openhands/runtime/utils/bash.py b/openhands/runtime/utils/bash.py index 15ce3572cb..fba16787c6 100644 --- a/openhands/runtime/utils/bash.py +++ b/openhands/runtime/utils/bash.py @@ -1,6 +1,18 @@ +import os +import re + import bashlex +import pexpect from openhands.core.logger import openhands_logger as logger +from openhands.events.action import CmdRunAction +from openhands.events.event import EventSource +from openhands.events.observation import ( + CmdOutputObservation, + FatalErrorObservation, +) + +SOFT_TIMEOUT_SECONDS = 5 def split_bash_commands(commands): @@ -52,3 +64,271 @@ def split_bash_commands(commands): result.append(remaining) logger.debug(f'BASH PARSING result.append(remaining): {result[-1]}') return result + + +class BashSession: + """A class that maintains a pexpect process and provides a simple interface for running commands and interacting with the shell.""" + + def __init__(self, work_dir: str, username: str): + self._pwd = work_dir + + self.shell = pexpect.spawn( + f'su {username}', + encoding='utf-8', + codec_errors='replace', + echo=False, + ) + self._init_bash_shell(work_dir) + + def close(self): + self.shell.close() + + @property + def pwd(self): + return self._pwd + + @property + def workdir(self): + return self._get_working_directory() + + def _get_working_directory(self): + # NOTE: this is part of initialization, so we hard code the timeout + result, exit_code = self._execute_bash('pwd', timeout=60, keep_prompt=False) + if exit_code != 0: + raise RuntimeError( + f'Failed to get working directory (exit code: {exit_code}): {result}' + ) + return result.strip() + + def _init_bash_shell(self, work_dir: str): + self.__bash_PS1 = ( + r'[PEXPECT_BEGIN]\n' + r'$(which python >/dev/null 2>&1 && echo "[Python Interpreter: $(which python)]\n")' + r'\u@\h:\w\n' + r'[PEXPECT_END]' + ) + + # This should NOT match "PS1=\u@\h:\w [PEXPECT]$" when `env` is executed + self.__bash_expect_regex = r'\[PEXPECT_BEGIN\]\s*(.*?)\s*([a-z0-9_-]*)@([a-zA-Z0-9.-]*):(.+)\s*\[PEXPECT_END\]' + # Set umask to allow group write permissions + self.shell.sendline(f'umask 002; export PS1="{self.__bash_PS1}"; export PS2=""') + self.shell.expect(self.__bash_expect_regex) + + self.shell.sendline( + f'if [ ! -d "{work_dir}" ]; then mkdir -p "{work_dir}"; fi && cd "{work_dir}"' + ) + self.shell.expect(self.__bash_expect_regex) + logger.debug( + f'Bash initialized. Working directory: {work_dir}. Output: [{self.shell.before}]' + ) + # Ensure the group has write permissions on the working directory + self.shell.sendline(f'chmod g+rw "{work_dir}"') + self.shell.expect(self.__bash_expect_regex) + + def _get_bash_prompt_and_update_pwd(self): + ps1 = self.shell.after + if ps1 == pexpect.EOF: + logger.error(f'Bash shell EOF! {self.shell.after=}, {self.shell.before=}') + raise RuntimeError('Bash shell EOF') + if ps1 == pexpect.TIMEOUT: + logger.warning('Bash shell timeout') + return '' + + # begin at the last occurrence of '[PEXPECT_BEGIN]'. + # In multi-line bash commands, the prompt will be repeated + # and the matched regex captures all of them + # - we only want the last one (newest prompt) + _begin_pos = ps1.rfind('[PEXPECT_BEGIN]') + if _begin_pos != -1: + ps1 = ps1[_begin_pos:] + + # parse the ps1 to get username, hostname, and working directory + matched = re.match(self.__bash_expect_regex, ps1) + assert ( + matched is not None + ), f'Failed to parse bash prompt: {ps1}. This should not happen.' + other_info, username, hostname, working_dir = matched.groups() + working_dir = working_dir.rstrip() + self._pwd = os.path.expanduser(working_dir) + + # re-assemble the prompt + # ignore the hostname AND use 'openhands-workspace' + prompt = f'{other_info.strip()}\n{username}@openhands-workspace:{working_dir} ' + if username == 'root': + prompt += '#' + else: + prompt += '$' + return prompt + ' ' + + def _execute_bash( + self, + command: str, + timeout: int, + keep_prompt: bool = True, + kill_on_timeout: bool = True, + ) -> tuple[str, int]: + logger.debug(f'Executing command: {command}') + self.shell.sendline(command) + return self._continue_bash( + timeout=timeout, keep_prompt=keep_prompt, kill_on_timeout=kill_on_timeout + ) + + def _interrupt_bash( + self, + action_timeout: int | None, + interrupt_timeout: int | None = None, + max_retries: int = 2, + ) -> tuple[str, int]: + interrupt_timeout = interrupt_timeout or 1 # default timeout for SIGINT + # try to interrupt the bash shell use SIGINT + while max_retries > 0: + self.shell.sendintr() # send SIGINT to the shell + logger.debug('Sent SIGINT to bash. Waiting for output...') + try: + self.shell.expect(self.__bash_expect_regex, timeout=interrupt_timeout) + output = self.shell.before + logger.debug(f'Received output after SIGINT: {output}') + exit_code = 130 # SIGINT + + _additional_msg = '' + if action_timeout is not None: + _additional_msg = ( + f'Command timed out after {action_timeout} seconds. ' + ) + output += ( + '\r\n\r\n' + + f'[{_additional_msg}SIGINT was sent to interrupt the command.]' + ) + return output, exit_code + except pexpect.TIMEOUT as e: + logger.warning(f'Bash pexpect.TIMEOUT while waiting for SIGINT: {e}') + max_retries -= 1 + + # fall back to send control-z + logger.error( + 'Failed to get output after SIGINT. Max retries reached. Sending control-z...' + ) + self.shell.sendcontrol('z') + self.shell.expect(self.__bash_expect_regex) + output = self.shell.before + logger.debug(f'Received output after control-z: {output}') + # Try to kill the job + self.shell.sendline('kill -9 %1') + self.shell.expect(self.__bash_expect_regex) + logger.debug(f'Received output after killing job %1: {self.shell.before}') + output += self.shell.before + + _additional_msg = '' + if action_timeout is not None: + _additional_msg = f'Command timed out after {action_timeout} seconds. ' + output += ( + '\r\n\r\n' + + f'[{_additional_msg}SIGINT was sent to interrupt the command, but failed. The command was killed.]' + ) + + # Try to get the exit code again + self.shell.sendline('echo $?') + self.shell.expect(self.__bash_expect_regex) + _exit_code_output = self.shell.before + exit_code = self._parse_exit_code(_exit_code_output) + + return output, exit_code + + def _parse_exit_code(self, output: str) -> int: + try: + exit_code = int(output.strip().split()[0]) + except Exception: + logger.error('Error getting exit code from bash script') + # If we try to run an invalid shell script the output sometimes includes error text + # rather than the error code - we assume this is an error + exit_code = 2 + return exit_code + + def _continue_bash( + self, + timeout: int, + keep_prompt: bool = True, + kill_on_timeout: bool = True, + ) -> tuple[str, int]: + logger.debug(f'Continuing bash with timeout={timeout}') + try: + self.shell.expect(self.__bash_expect_regex, timeout=timeout) + + output = self.shell.before + + # Get exit code + self.shell.sendline('echo $?') + logger.debug('Requesting exit code...') + self.shell.expect(self.__bash_expect_regex, timeout=timeout) + _exit_code_output = self.shell.before + exit_code = self._parse_exit_code(_exit_code_output) + except pexpect.TIMEOUT as e: + logger.warning(f'Bash pexpect.TIMEOUT while executing bash command: {e}') + if kill_on_timeout: + output, exit_code = self._interrupt_bash(action_timeout=timeout) + else: + output = self.shell.before or '' + exit_code = -1 + finally: + bash_prompt = self._get_bash_prompt_and_update_pwd() + if keep_prompt: + output += '\r\n' + bash_prompt + return output, exit_code + + def run(self, action: CmdRunAction) -> CmdOutputObservation | FatalErrorObservation: + try: + assert ( + action.timeout is not None + ), f'Timeout argument is required for CmdRunAction: {action}' + commands = split_bash_commands(action.command) + all_output = '' + python_interpreter = '' + for command in commands: + if command == '': + output, exit_code = self._continue_bash( + timeout=SOFT_TIMEOUT_SECONDS, + keep_prompt=action.keep_prompt, + kill_on_timeout=False, + ) + elif command.lower() == 'ctrl+c': + output, exit_code = self._interrupt_bash( + action_timeout=None, # intentionally None + ) + else: + output, exit_code = self._execute_bash( + command, + timeout=SOFT_TIMEOUT_SECONDS + if not action.blocking + else action.timeout, + keep_prompt=action.keep_prompt, + kill_on_timeout=False if not action.blocking else True, + ) + # Get rid of the python interpreter string from each line of the output. + # We need it only once at the end. + parts = output.rsplit('[Python Interpreter: ', 1) + output = parts[0] + if len(parts) == 2: + python_interpreter = '[Python Interpreter: ' + parts[1] + if all_output: + # previous output already exists so we add a newline + all_output += '\r\n' + + # If the command originated with the agent, append the command that was run... + if action.source == EventSource.AGENT: + all_output += command + '\r\n' + + all_output += str(output) + if exit_code != 0: + break + return CmdOutputObservation( + command_id=-1, + content=all_output.rstrip('\r\n'), + command=action.command, + hidden=action.hidden, + exit_code=exit_code, + interpreter_details=python_interpreter, + ) + except UnicodeDecodeError as e: + return FatalErrorObservation( + f'Runtime bash execution failed: Command output could not be decoded as utf-8. {str(e)}' + ) diff --git a/openhands/runtime/utils/runtime_init.py b/openhands/runtime/utils/runtime_init.py new file mode 100644 index 0000000000..9d42f5483a --- /dev/null +++ b/openhands/runtime/utils/runtime_init.py @@ -0,0 +1,103 @@ +import subprocess + +from openhands.core.logger import openhands_logger as logger + + +def init_user_and_working_directory( + username: str, user_id: int, initial_pwd: str +) -> int | None: + """Create working directory and user if not exists. + It performs the following steps effectively: + * Creates the Working Directory: + - Uses mkdir -p to create the directory. + - Sets ownership to username:root. + - Adjusts permissions to be readable and writable by group and others. + * User Verification and Creation: + - Checks if the user exists using id -u. + - If the user exists with the correct UID, it skips creation. + - If the UID differs, it logs a warning and return an updated user_id. + - If the user doesn't exist, it proceeds to create the user. + * Sudo Configuration: + - Appends %sudo ALL=(ALL) NOPASSWD:ALL to /etc/sudoers to grant + passwordless sudo access to the sudo group. + - Adds the user to the sudo group with the useradd command, handling + UID conflicts by incrementing the UID if necessary. + + Args: + username (str): The username to create. + user_id (int): The user ID to assign to the user. + initial_pwd (str): The initial working directory to create. + + Returns: + int | None: The user ID if it was updated, None otherwise. + """ + + # First create the working directory, independent of the user + logger.info(f'Client working directory: {initial_pwd}') + command = f'umask 002; mkdir -p {initial_pwd}' + output = subprocess.run(command, shell=True, capture_output=True) + out_str = output.stdout.decode() + + command = f'chown -R {username}:root {initial_pwd}' + output = subprocess.run(command, shell=True, capture_output=True) + out_str += output.stdout.decode() + + command = f'chmod g+rw {initial_pwd}' + output = subprocess.run(command, shell=True, capture_output=True) + out_str += output.stdout.decode() + logger.debug(f'Created working directory. Output: [{out_str}]') + + # Skip root since it is already created + if username == 'root': + return None + + # Check if the username already exists + existing_user_id = -1 + try: + result = subprocess.run( + f'id -u {username}', shell=True, check=True, capture_output=True + ) + existing_user_id = int(result.stdout.decode().strip()) + + # The user ID already exists, skip setup + if existing_user_id == user_id: + logger.debug( + f'User `{username}` already has the provided UID {user_id}. Skipping user setup.' + ) + else: + logger.warning( + f'User `{username}` already exists with UID {existing_user_id}. Skipping user setup.' + ) + return existing_user_id + return None + except subprocess.CalledProcessError as e: + # Returncode 1 indicates, that the user does not exist yet + if e.returncode == 1: + logger.debug( + f'User `{username}` does not exist. Proceeding with user creation.' + ) + else: + logger.error(f'Error checking user `{username}`, skipping setup:\n{e}\n') + raise + + # Add sudoer + sudoer_line = r"echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers" + output = subprocess.run(sudoer_line, shell=True, capture_output=True) + if output.returncode != 0: + raise RuntimeError(f'Failed to add sudoer: {output.stderr.decode()}') + logger.debug(f'Added sudoer successfully. Output: [{output.stdout.decode()}]') + + command = ( + f'useradd -rm -d /home/{username} -s /bin/bash ' + f'-g root -G sudo -u {user_id} {username}' + ) + output = subprocess.run(command, shell=True, capture_output=True) + if output.returncode == 0: + logger.debug( + f'Added user `{username}` successfully with UID {user_id}. Output: [{output.stdout.decode()}]' + ) + else: + raise RuntimeError( + f'Failed to create user `{username}` with UID {user_id}. Output: [{output.stderr.decode()}]' + ) + return None