From 5d69e606ebddc9f99009d13930cd152c4d0a6251 Mon Sep 17 00:00:00 2001 From: Graham Neubig Date: Sun, 22 Jun 2025 20:17:40 -0400 Subject: [PATCH] feat: Add Windows PowerShell support to CLI runtime (#9211) Co-authored-by: openhands --- docs/usage/windows-without-wsl.mdx | 55 ++++++++- openhands/runtime/impl/cli/cli_runtime.py | 108 +++++++++++++++++- openhands/runtime/utils/windows_bash.py | 45 +++++--- openhands/runtime/utils/windows_exceptions.py | 15 +++ 4 files changed, 202 insertions(+), 21 deletions(-) create mode 100644 openhands/runtime/utils/windows_exceptions.py diff --git a/docs/usage/windows-without-wsl.mdx b/docs/usage/windows-without-wsl.mdx index 0ebe321b3e..f09d874a2e 100644 --- a/docs/usage/windows-without-wsl.mdx +++ b/docs/usage/windows-without-wsl.mdx @@ -133,13 +133,66 @@ This guide provides step-by-step instructions for running OpenHands on a Windows > **Note**: If you're running the frontend in development mode (using `npm run dev`), use port 3001 instead: `http://localhost:3001` +## Installing and Running the CLI + +To install and run the OpenHands CLI on Windows without WSL, follow these steps: + +### 1. Install uv (Python Package Manager) + +Open PowerShell as Administrator and run: + +```powershell +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" +``` + +### 2. Install .NET SDK (Required) + +The OpenHands CLI **requires** the .NET Core runtime for PowerShell integration. Without it, the CLI will fail to start with a `coreclr` error. Install the .NET SDK which includes the runtime: + +```powershell +winget install Microsoft.DotNet.SDK.8 +``` + +Alternatively, you can download and install the .NET SDK from the [official Microsoft website](https://dotnet.microsoft.com/download). + +After installation, restart your PowerShell session to ensure the environment variables are updated. + +### 3. Install and Run OpenHands + +After installing the prerequisites, you can install and run OpenHands with: + +```powershell +uvx --python 3.12 --from openhands-ai openhands +``` + +### Troubleshooting CLI Issues + +#### CoreCLR Error + +If you encounter an error like `Failed to load CoreCLR` or `pythonnet.load('coreclr')` when running OpenHands CLI, this indicates that the .NET Core runtime is missing or not properly configured. To fix this: + +1. Install the .NET SDK as described in step 2 above +2. Verify that your system PATH includes the .NET SDK directories +3. Restart your PowerShell session completely after installing the .NET SDK +4. Make sure you're using PowerShell 7 (pwsh) rather than Windows PowerShell + +To verify your .NET installation, run: + +```powershell +dotnet --info +``` + +This should display information about your installed .NET SDKs and runtimes. If this command fails, the .NET SDK is not properly installed or not in your PATH. + +If the issue persists after installing the .NET SDK, try installing the specific .NET Runtime version 6.0 or later from the [.NET download page](https://dotnet.microsoft.com/download). + ## Limitations on Windows When running OpenHands on Windows without WSL or Docker, be aware of the following limitations: 1. **Browser Tool Not Supported**: The browser tool is not currently supported on Windows. -2. **.NET Core Requirement**: The PowerShell integration requires .NET Core Runtime to be installed. If .NET Core is not available, OpenHands will automatically fall back to a more limited PowerShell implementation with reduced functionality. +2. **.NET Core Requirement**: The PowerShell integration requires .NET Core Runtime to be installed. The CLI implementation attempts to load the CoreCLR at startup with `pythonnet.load('coreclr')` and will fail with an error if .NET Core is not properly installed. 3. **Interactive Shell Commands**: Some interactive shell commands may not work as expected. The PowerShell session implementation has limitations compared to the bash session used on Linux/macOS. diff --git a/openhands/runtime/impl/cli/cli_runtime.py b/openhands/runtime/impl/cli/cli_runtime.py index 0b1283ee79..1b076d2084 100644 --- a/openhands/runtime/impl/cli/cli_runtime.py +++ b/openhands/runtime/impl/cli/cli_runtime.py @@ -9,11 +9,12 @@ import select import shutil import signal import subprocess +import sys import tempfile import time import zipfile from pathlib import Path -from typing import Any, Callable +from typing import TYPE_CHECKING, Any, Callable from binaryornot.check import is_binary from openhands_aci.editor.editor import OHEditor @@ -51,6 +52,41 @@ from openhands.runtime.base import Runtime from openhands.runtime.plugins import PluginRequirement from openhands.runtime.runtime_status import RuntimeStatus +if TYPE_CHECKING: + from openhands.runtime.utils.windows_bash import WindowsPowershellSession + +# Import Windows PowerShell support if on Windows +if sys.platform == 'win32': + try: + from openhands.runtime.utils.windows_bash import WindowsPowershellSession + from openhands.runtime.utils.windows_exceptions import DotNetMissingError + except (ImportError, DotNetMissingError) as err: + # Print a user-friendly error message without stack trace + friendly_message = """ +ERROR: PowerShell and .NET SDK are required but not properly configured + +The .NET SDK and PowerShell are required for OpenHands CLI on Windows. +PowerShell integration cannot function without .NET Core. + +Please install the .NET SDK by following the instructions at: +https://docs.all-hands.dev/usage/windows-without-wsl + +After installing .NET SDK, restart your terminal and try again. +""" + print(friendly_message, file=sys.stderr) + logger.error( + f'Windows runtime initialization failed: {type(err).__name__}: {str(err)}' + ) + if ( + isinstance(err, DotNetMissingError) + and hasattr(err, 'details') + and err.details + ): + logger.debug(f'Details: {err.details}') + + # Exit the program with an error code + sys.exit(1) + class CLIRuntime(Runtime): """ @@ -119,6 +155,10 @@ class CLIRuntime(Runtime): self.file_editor = OHEditor(workspace_root=self._workspace_path) self._shell_stream_callback: Callable[[str], None] | None = None + # Initialize PowerShell session on Windows + self._is_windows = sys.platform == 'win32' + self._powershell_session: WindowsPowershellSession | None = None + logger.warning( 'Initializing CLIRuntime. WARNING: NO SANDBOX IS USED. ' 'This runtime executes commands directly on the local system. ' @@ -135,6 +175,15 @@ class CLIRuntime(Runtime): # Change to the workspace directory os.chdir(self._workspace_path) + # Initialize PowerShell session if on Windows + if self._is_windows: + self._powershell_session = WindowsPowershellSession( + work_dir=self._workspace_path, + username=None, # Use current user + no_change_timeout_seconds=30, + max_memory_mb=None, + ) + if not self.attach_to_existing: await asyncio.to_thread(self.setup_initial_env) @@ -241,6 +290,40 @@ class CLIRuntime(Runtime): except Exception as e: logger.error(f'Error: {e}') + def _execute_powershell_command( + self, command: str, timeout: float + ) -> CmdOutputObservation | ErrorObservation: + """ + Execute a command using PowerShell session on Windows. + Args: + command: The command to execute + timeout: Timeout in seconds for the command + Returns: + CmdOutputObservation containing the complete output and exit code + """ + if self._powershell_session is None: + return ErrorObservation( + content='PowerShell session is not available.', + error_id='POWERSHELL_SESSION_ERROR', + ) + + try: + # Create a CmdRunAction for the PowerShell session + from openhands.events.action import CmdRunAction + + ps_action = CmdRunAction(command=command) + ps_action.set_hard_timeout(timeout) + + # Execute the command using the PowerShell session + return self._powershell_session.execute(ps_action) + + except Exception as e: + logger.error(f'Error executing PowerShell command "{command}": {e}') + return ErrorObservation( + content=f'Error executing PowerShell command "{command}": {str(e)}', + error_id='POWERSHELL_EXECUTION_ERROR', + ) + def _execute_shell_command( self, command: str, timeout: float ) -> CmdOutputObservation: @@ -378,9 +461,16 @@ class CLIRuntime(Runtime): 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 - ) + + # Use PowerShell on Windows if available, otherwise use subprocess + if self._is_windows and self._powershell_session is not None: + return self._execute_powershell_command( + action.command, timeout=effective_timeout + ) + else: + return self._execute_shell_command( + action.command, timeout=effective_timeout + ) except Exception as e: logger.error( f'Error in CLIRuntime.run for command "{action.command}": {str(e)}' @@ -737,6 +827,16 @@ class CLIRuntime(Runtime): raise RuntimeError(f'Error creating zip file: {str(e)}') def close(self) -> None: + # Clean up PowerShell session if it exists + if self._powershell_session is not None: + try: + self._powershell_session.close() + logger.debug('PowerShell session closed successfully.') + except Exception as e: + logger.warning(f'Error closing PowerShell session: {e}') + finally: + self._powershell_session = None + self._runtime_initialized = False super().close() diff --git a/openhands/runtime/utils/windows_bash.py b/openhands/runtime/utils/windows_bash.py index d777e8c3f3..76c7ff039b 100644 --- a/openhands/runtime/utils/windows_bash.py +++ b/openhands/runtime/utils/windows_bash.py @@ -21,21 +21,31 @@ from openhands.events.observation.commands import ( CmdOutputObservation, ) from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE +from openhands.runtime.utils.windows_exceptions import DotNetMissingError from openhands.utils.shutdown_listener import should_continue -pythonnet.load('coreclr') -logger.info("Successfully called pythonnet.load('coreclr')") - -# Now that pythonnet is initialized, import clr and System try: - import clr + pythonnet.load('coreclr') + logger.info("Successfully called pythonnet.load('coreclr')") - logger.debug(f'Imported clr module from: {clr.__file__}') - # Load System assembly *after* pythonnet is initialized - clr.AddReference('System') - import System -except Exception as clr_sys_ex: - raise RuntimeError(f'FATAL: Failed to import clr or System. Error: {clr_sys_ex}') + # Now that pythonnet is initialized, import clr and System + try: + import clr + + logger.debug(f'Imported clr module from: {clr.__file__}') + # Load System assembly *after* pythonnet is initialized + clr.AddReference('System') + import System + except Exception as clr_sys_ex: + error_msg = 'Failed to import .NET components.' + details = str(clr_sys_ex) + logger.error(f'{error_msg} Details: {details}') + raise DotNetMissingError(error_msg, details) +except Exception as coreclr_ex: + error_msg = 'Failed to load CoreCLR.' + details = str(coreclr_ex) + logger.error(f'{error_msg} Details: {details}') + raise DotNetMissingError(error_msg, details) # Attempt to load the PowerShell SDK assembly only if clr and System loaded ps_sdk_path = None @@ -78,9 +88,10 @@ try: RunspaceState, ) except Exception as e: - raise RuntimeError( - f'FATAL: Failed to load PowerShell SDK components. Error: {e}. Check pythonnet installation and .NET Runtime compatibility. Path searched: {ps_sdk_path}' - ) + error_msg = 'Failed to load PowerShell SDK components.' + details = f'{str(e)} (Path searched: {ps_sdk_path})' + logger.error(f'{error_msg} Details: {details}') + raise DotNetMissingError(error_msg, details) class WindowsPowershellSession: @@ -115,9 +126,11 @@ class WindowsPowershellSession: if PowerShell is None: # Check if SDK loading failed during module import # Logged critical error during import, just raise here to prevent instantiation - raise RuntimeError( - 'PowerShell SDK (System.Management.Automation.dll) could not be loaded. Cannot initialize WindowsPowershellSession.' + error_msg = ( + 'PowerShell SDK (System.Management.Automation.dll) could not be loaded.' ) + logger.error(error_msg) + raise DotNetMissingError(error_msg) self.work_dir = os.path.abspath(work_dir) self.username = username diff --git a/openhands/runtime/utils/windows_exceptions.py b/openhands/runtime/utils/windows_exceptions.py new file mode 100644 index 0000000000..bb1917abff --- /dev/null +++ b/openhands/runtime/utils/windows_exceptions.py @@ -0,0 +1,15 @@ +""" +Custom exceptions for Windows-specific runtime issues. +""" + + +class DotNetMissingError(Exception): + """ + Exception raised when .NET SDK or CoreCLR is missing or cannot be loaded. + This is used to provide a cleaner error message to users without a full stack trace. + """ + + def __init__(self, message: str, details: str | None = None): + self.message = message + self.details = details + super().__init__(message)