Explorar el Código

refactor: move bash related logic into `BashSession` for cleaner code (#4527)

Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
Xingyao Wang hace 1 año
padre
commit
349e2dbe50

+ 38 - 356
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.'
-                )
-            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'
+                IPythonRunCellAction(
+                    code=f'import os; os.chdir("{self.bash_session.pwd}")'
                 )
-                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()
 
 

+ 1 - 2
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']

+ 280 - 0
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)}'
+            )

+ 103 - 0
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