mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-08 22:38:05 -05:00
Add windows local runtime support with PowerShell (#7410)
Co-authored-by: Boxuan Li (from Dev Box) <boxuanli@microsoft.com> Co-authored-by: Graham Neubig <neubig@gmail.com> Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
This commit is contained in:
25
.github/workflows/py-unit-tests.yml
vendored
25
.github/workflows/py-unit-tests.yml
vendored
@@ -53,3 +53,28 @@ jobs:
|
||||
uses: codecov/codecov-action@v5
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
# Run specific Windows python tests
|
||||
test-on-windows:
|
||||
name: Python Tests on Windows
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.12']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pipx
|
||||
run: pip install pipx
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'poetry'
|
||||
- name: Install Python dependencies using Poetry
|
||||
run: poetry install --without evaluation
|
||||
- name: Run Windows unit tests
|
||||
run: poetry run pytest -svv tests/unit/test_windows_bash.py
|
||||
- name: Run Windows runtime tests
|
||||
run: $env:TEST_RUNTIME="local"; poetry run pytest -svv tests/runtime/test_bash.py
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import copy
|
||||
import os
|
||||
import sys
|
||||
from collections import deque
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -119,8 +120,11 @@ class CodeActAgent(Agent):
|
||||
if self.config.enable_finish:
|
||||
tools.append(FinishTool)
|
||||
if self.config.enable_browsing:
|
||||
tools.append(WebReadTool)
|
||||
tools.append(BrowserTool)
|
||||
if sys.platform == 'win32':
|
||||
logger.warning('Windows runtime does not support browsing yet')
|
||||
else:
|
||||
tools.append(WebReadTool)
|
||||
tools.append(BrowserTool)
|
||||
if self.config.enable_jupyter:
|
||||
tools.append(IPythonTool)
|
||||
if self.config.enable_llm_editor:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import sys
|
||||
|
||||
from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk
|
||||
|
||||
_DETAILED_BASH_DESCRIPTION = """Execute a bash command in the terminal within a persistent shell session.
|
||||
@@ -28,27 +30,35 @@ _SHORT_BASH_DESCRIPTION = """Execute a bash command in the terminal.
|
||||
* One command at a time: You can only execute one bash command at a time. If you need to run multiple commands sequentially, you can use `&&` or `;` to chain them together."""
|
||||
|
||||
|
||||
def refine_prompt(prompt: str):
|
||||
if sys.platform == 'win32':
|
||||
return prompt.replace('bash', 'powershell')
|
||||
return prompt
|
||||
|
||||
|
||||
def create_cmd_run_tool(
|
||||
use_short_description: bool = False,
|
||||
) -> ChatCompletionToolParam:
|
||||
description = (
|
||||
_SHORT_BASH_DESCRIPTION if use_short_description else _DETAILED_BASH_DESCRIPTION
|
||||
)
|
||||
description = _SHORT_BASH_DESCRIPTION if use_short_description else _DETAILED_BASH_DESCRIPTION
|
||||
return ChatCompletionToolParam(
|
||||
type='function',
|
||||
function=ChatCompletionToolParamFunctionChunk(
|
||||
name='execute_bash',
|
||||
description=description,
|
||||
name=refine_prompt('execute_bash'),
|
||||
description=refine_prompt(description),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'command': {
|
||||
'type': 'string',
|
||||
'description': 'The bash command to execute. Can be empty string to view additional logs when previous exit code is `-1`. Can be `C-c` (Ctrl+C) to interrupt the currently running process. Note: You can only execute one bash command at a time. If you need to run multiple commands sequentially, you can use `&&` or `;` to chain them together.',
|
||||
'description': refine_prompt(
|
||||
'The bash command to execute. Can be empty string to view additional logs when previous exit code is `-1`. Can be `C-c` (Ctrl+C) to interrupt the currently running process. Note: You can only execute one bash command at a time. If you need to run multiple commands sequentially, you can use `&&` or `;` to chain them together.'
|
||||
),
|
||||
},
|
||||
'is_input': {
|
||||
'type': 'string',
|
||||
'description': 'If True, the command is an input to the running process. If False, the command is a bash command to be executed in the terminal. Default is False.',
|
||||
'description': refine_prompt(
|
||||
'If True, the command is an input to the running process. If False, the command is a bash command to be executed in the terminal. Default is False.'
|
||||
),
|
||||
'enum': ['true', 'false'],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ We follow format from: https://docs.litellm.ai/docs/completion/function_call
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from typing import Iterable
|
||||
|
||||
from litellm import ChatCompletionToolParam
|
||||
@@ -47,8 +48,15 @@ Reminder:
|
||||
|
||||
STOP_WORDS = ['</function']
|
||||
|
||||
|
||||
def refine_prompt(prompt: str):
|
||||
if sys.platform == 'win32':
|
||||
return prompt.replace('bash', 'powershell')
|
||||
return prompt
|
||||
|
||||
|
||||
# NOTE: we need to make sure this example is always in-sync with the tool interface designed in openhands/agenthub/codeact_agent/function_calling.py
|
||||
IN_CONTEXT_LEARNING_EXAMPLE_PREFIX = """
|
||||
IN_CONTEXT_LEARNING_EXAMPLE_PREFIX = refine_prompt("""
|
||||
Here's a running example of how to perform a task with the provided tools.
|
||||
|
||||
--------------------- START OF EXAMPLE ---------------------
|
||||
@@ -218,7 +226,7 @@ The server is running on port 5000 with PID 126. You can access the list of numb
|
||||
Do NOT assume the environment is the same as in the example above.
|
||||
|
||||
--------------------- NEW TASK DESCRIPTION ---------------------
|
||||
""".lstrip()
|
||||
""").lstrip()
|
||||
|
||||
IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX = """
|
||||
--------------------- END OF NEW TASK DESCRIPTION ---------------------
|
||||
@@ -351,7 +359,8 @@ def convert_fncall_messages_to_non_fncall_messages(
|
||||
and any(
|
||||
(
|
||||
tool['type'] == 'function'
|
||||
and tool['function']['name'] == 'execute_bash'
|
||||
and tool['function']['name']
|
||||
== refine_prompt('execute_bash')
|
||||
and 'command'
|
||||
in tool['function']['parameters']['properties']
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import traceback
|
||||
@@ -76,6 +77,10 @@ from openhands.utils.async_utils import call_sync_from_async, wait_all
|
||||
mcp_router_logger.setLevel(logger.getEffectiveLevel())
|
||||
|
||||
|
||||
if sys.platform == 'win32':
|
||||
from openhands.runtime.utils.windows_bash import WindowsPowershellSession
|
||||
|
||||
|
||||
class ActionRequest(BaseModel):
|
||||
action: dict
|
||||
|
||||
@@ -167,13 +172,14 @@ class ActionExecutor:
|
||||
if _updated_user_id is not None:
|
||||
self.user_id = _updated_user_id
|
||||
|
||||
self.bash_session: BashSession | None = None
|
||||
self.bash_session: BashSession | 'WindowsPowershellSession' | None = None # type: ignore[name-defined]
|
||||
self.lock = asyncio.Lock()
|
||||
self.plugins: dict[str, Plugin] = {}
|
||||
self.file_editor = OHEditor(workspace_root=self._initial_cwd)
|
||||
self.browser: BrowserEnv | None = None
|
||||
self.browser_init_task: asyncio.Task | None = None
|
||||
self.browsergym_eval_env = browsergym_eval_env
|
||||
|
||||
self.start_time = time.time()
|
||||
self.last_execution_time = self.start_time
|
||||
self._initialized = False
|
||||
@@ -199,6 +205,10 @@ class ActionExecutor:
|
||||
|
||||
async def _init_browser_async(self):
|
||||
"""Initialize the browser asynchronously."""
|
||||
if sys.platform == 'win32':
|
||||
logger.warning('Browser environment not supported on windows')
|
||||
return
|
||||
|
||||
logger.debug('Initializing browser asynchronously')
|
||||
try:
|
||||
self.browser = BrowserEnv(self.browsergym_eval_env)
|
||||
@@ -232,15 +242,25 @@ class ActionExecutor:
|
||||
async def ainit(self):
|
||||
# bash needs to be initialized first
|
||||
logger.debug('Initializing bash session')
|
||||
self.bash_session = BashSession(
|
||||
work_dir=self._initial_cwd,
|
||||
username=self.username,
|
||||
no_change_timeout_seconds=int(
|
||||
os.environ.get('NO_CHANGE_TIMEOUT_SECONDS', 10)
|
||||
),
|
||||
max_memory_mb=self.max_memory_gb * 1024 if self.max_memory_gb else None,
|
||||
)
|
||||
self.bash_session.initialize()
|
||||
if sys.platform == 'win32':
|
||||
self.bash_session = WindowsPowershellSession( # type: ignore[name-defined]
|
||||
work_dir=self._initial_cwd,
|
||||
username=self.username,
|
||||
no_change_timeout_seconds=int(
|
||||
os.environ.get('NO_CHANGE_TIMEOUT_SECONDS', 10)
|
||||
),
|
||||
max_memory_mb=self.max_memory_gb * 1024 if self.max_memory_gb else None,
|
||||
)
|
||||
else:
|
||||
self.bash_session = BashSession(
|
||||
work_dir=self._initial_cwd,
|
||||
username=self.username,
|
||||
no_change_timeout_seconds=int(
|
||||
os.environ.get('NO_CHANGE_TIMEOUT_SECONDS', 10)
|
||||
),
|
||||
max_memory_mb=self.max_memory_gb * 1024 if self.max_memory_gb else None,
|
||||
)
|
||||
self.bash_session.initialize()
|
||||
logger.debug('Bash session initialized')
|
||||
|
||||
# Start browser initialization in the background
|
||||
@@ -282,19 +302,55 @@ class ActionExecutor:
|
||||
logger.debug(f'Initializing plugin: {plugin.name}')
|
||||
|
||||
if isinstance(plugin, JupyterPlugin):
|
||||
# Escape backslashes in Windows path
|
||||
cwd = self.bash_session.cwd.replace('\\', '/')
|
||||
await self.run_ipython(
|
||||
IPythonRunCellAction(
|
||||
code=f'import os; os.chdir("{self.bash_session.cwd}")'
|
||||
)
|
||||
IPythonRunCellAction(code=f'import os; os.chdir(r"{cwd}")')
|
||||
)
|
||||
|
||||
async def _init_bash_commands(self):
|
||||
INIT_COMMANDS = [
|
||||
'git config --file ./.git_config user.name "openhands" && git config --file ./.git_config user.email "openhands@all-hands.dev" && alias git="git --no-pager" && export GIT_CONFIG=$(pwd)/.git_config'
|
||||
if os.environ.get('LOCAL_RUNTIME_MODE') == '1'
|
||||
else 'git config --global user.name "openhands" && git config --global user.email "openhands@all-hands.dev" && alias git="git --no-pager"'
|
||||
]
|
||||
logger.debug(f'Initializing by running {len(INIT_COMMANDS)} bash commands...')
|
||||
INIT_COMMANDS = []
|
||||
is_local_runtime = os.environ.get('LOCAL_RUNTIME_MODE') == '1'
|
||||
is_windows = sys.platform == 'win32'
|
||||
|
||||
# Determine git config commands based on platform and runtime mode
|
||||
if is_local_runtime:
|
||||
if is_windows:
|
||||
# Windows, local - split into separate commands
|
||||
INIT_COMMANDS.append(
|
||||
'git config --file ./.git_config user.name "openhands"'
|
||||
)
|
||||
INIT_COMMANDS.append(
|
||||
'git config --file ./.git_config user.email "openhands@all-hands.dev"'
|
||||
)
|
||||
INIT_COMMANDS.append(
|
||||
'$env:GIT_CONFIG = (Join-Path (Get-Location) ".git_config")'
|
||||
)
|
||||
else:
|
||||
# Linux/macOS, local
|
||||
base_git_config = (
|
||||
'git config --file ./.git_config user.name "openhands" && '
|
||||
'git config --file ./.git_config user.email "openhands@all-hands.dev" && '
|
||||
'export GIT_CONFIG=$(pwd)/.git_config'
|
||||
)
|
||||
INIT_COMMANDS.append(base_git_config)
|
||||
else:
|
||||
# Non-local (implies Linux/macOS)
|
||||
base_git_config = (
|
||||
'git config --global user.name "openhands" && '
|
||||
'git config --global user.email "openhands@all-hands.dev"'
|
||||
)
|
||||
INIT_COMMANDS.append(base_git_config)
|
||||
|
||||
# Determine no-pager command
|
||||
if is_windows:
|
||||
no_pager_cmd = 'function git { git.exe --no-pager $args }'
|
||||
else:
|
||||
no_pager_cmd = 'alias git="git --no-pager"'
|
||||
|
||||
INIT_COMMANDS.append(no_pager_cmd)
|
||||
|
||||
logger.info(f'Initializing by running {len(INIT_COMMANDS)} bash commands...')
|
||||
for command in INIT_COMMANDS:
|
||||
action = CmdRunAction(command=command)
|
||||
action.set_hard_timeout(300)
|
||||
@@ -345,9 +401,9 @@ class ActionExecutor:
|
||||
logger.debug(
|
||||
f'{self.bash_session.cwd} != {jupyter_cwd} -> reset Jupyter PWD'
|
||||
)
|
||||
reset_jupyter_cwd_code = (
|
||||
f'import os; os.chdir("{self.bash_session.cwd}")'
|
||||
)
|
||||
# escape windows paths
|
||||
cwd = self.bash_session.cwd.replace('\\', '/')
|
||||
reset_jupyter_cwd_code = f'import os; os.chdir("{cwd}")'
|
||||
_aux_action = IPythonRunCellAction(code=reset_jupyter_cwd_code)
|
||||
_reset_obs: IPythonRunCellObservation = await _jupyter_plugin.run(
|
||||
_aux_action
|
||||
@@ -527,10 +583,18 @@ class ActionExecutor:
|
||||
)
|
||||
|
||||
async def browse(self, action: BrowseURLAction) -> Observation:
|
||||
if self.browser is None:
|
||||
return ErrorObservation(
|
||||
'Browser functionality is not supported on Windows.'
|
||||
)
|
||||
await self._ensure_browser_ready()
|
||||
return await browse(action, self.browser)
|
||||
|
||||
async def browse_interactive(self, action: BrowseInteractiveAction) -> Observation:
|
||||
if self.browser is None:
|
||||
return ErrorObservation(
|
||||
'Browser functionality is not supported on Windows.'
|
||||
)
|
||||
await self._ensure_browser_ready()
|
||||
return await browse(action, self.browser)
|
||||
|
||||
@@ -726,7 +790,6 @@ if __name__ == '__main__':
|
||||
if not isinstance(action, Action):
|
||||
raise HTTPException(status_code=400, detail='Invalid action type')
|
||||
client.last_execution_time = time.time()
|
||||
|
||||
observation = await client.run_action(action)
|
||||
return event_to_dict(observation)
|
||||
except Exception as e:
|
||||
@@ -897,7 +960,7 @@ if __name__ == '__main__':
|
||||
|
||||
To list files:
|
||||
```sh
|
||||
curl http://localhost:3000/api/list-files
|
||||
curl -X POST -d '{"path": "/"}' http://localhost:3000/list_files
|
||||
```
|
||||
|
||||
Args:
|
||||
|
||||
@@ -158,7 +158,6 @@ class ActionExecutionClient(Runtime):
|
||||
|
||||
def copy_from(self, path: str) -> Path:
|
||||
"""Zip all files in the sandbox and return as a stream of bytes."""
|
||||
|
||||
try:
|
||||
params = {'path': path}
|
||||
with self.session.stream(
|
||||
@@ -183,25 +182,44 @@ class ActionExecutionClient(Runtime):
|
||||
if not os.path.exists(host_src):
|
||||
raise FileNotFoundError(f'Source file {host_src} does not exist')
|
||||
|
||||
temp_zip_path: str | None = None # Define temp_zip_path outside the try block
|
||||
|
||||
try:
|
||||
params = {'destination': sandbox_dest, 'recursive': str(recursive).lower()}
|
||||
file_to_upload = None
|
||||
upload_data = {}
|
||||
|
||||
if recursive:
|
||||
# Create and write the zip file inside the try block
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix='.zip', delete=False
|
||||
) as temp_zip:
|
||||
temp_zip_path = temp_zip.name
|
||||
|
||||
with ZipFile(temp_zip_path, 'w') as zipf:
|
||||
for root, _, files in os.walk(host_src):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
arcname = os.path.relpath(
|
||||
file_path, os.path.dirname(host_src)
|
||||
)
|
||||
zipf.write(file_path, arcname)
|
||||
try:
|
||||
with ZipFile(temp_zip_path, 'w') as zipf:
|
||||
for root, _, files in os.walk(host_src):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
arcname = os.path.relpath(
|
||||
file_path, os.path.dirname(host_src)
|
||||
)
|
||||
zipf.write(file_path, arcname)
|
||||
|
||||
upload_data = {'file': open(temp_zip_path, 'rb')}
|
||||
self.log(
|
||||
'debug',
|
||||
f'Opening temporary zip file for upload: {temp_zip_path}',
|
||||
)
|
||||
file_to_upload = open(temp_zip_path, 'rb')
|
||||
upload_data = {'file': file_to_upload}
|
||||
except Exception as e:
|
||||
# Ensure temp file is cleaned up if zipping fails
|
||||
if temp_zip_path and os.path.exists(temp_zip_path):
|
||||
os.unlink(temp_zip_path)
|
||||
raise e # Re-raise the exception after cleanup attempt
|
||||
else:
|
||||
upload_data = {'file': open(host_src, 'rb')}
|
||||
file_to_upload = open(host_src, 'rb')
|
||||
upload_data = {'file': file_to_upload}
|
||||
|
||||
params = {'destination': sandbox_dest, 'recursive': str(recursive).lower()}
|
||||
|
||||
@@ -217,11 +235,18 @@ class ActionExecutionClient(Runtime):
|
||||
f'Copy completed: host:{host_src} -> runtime:{sandbox_dest}. Response: {response.text}',
|
||||
)
|
||||
finally:
|
||||
if recursive:
|
||||
os.unlink(temp_zip_path)
|
||||
self.log(
|
||||
'debug', f'Copy completed: host:{host_src} -> runtime:{sandbox_dest}'
|
||||
)
|
||||
if file_to_upload:
|
||||
file_to_upload.close()
|
||||
|
||||
# Cleanup the temporary zip file if it was created
|
||||
if temp_zip_path and os.path.exists(temp_zip_path):
|
||||
try:
|
||||
os.unlink(temp_zip_path)
|
||||
except Exception as e:
|
||||
self.log(
|
||||
'error',
|
||||
f'Failed to delete temporary zip file {temp_zip_path}: {e}',
|
||||
)
|
||||
|
||||
def get_vscode_token(self) -> str:
|
||||
if self.vscode_enabled and self.runtime_initialized:
|
||||
|
||||
@@ -41,6 +41,18 @@ from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
|
||||
def get_user_info():
|
||||
"""Get user ID and username in a cross-platform way."""
|
||||
username = os.getenv('USER')
|
||||
if sys.platform == 'win32':
|
||||
# On Windows, we don't use user IDs the same way
|
||||
# Return a default value that won't cause issues
|
||||
return 1000, username
|
||||
else:
|
||||
# On Unix systems, use os.getuid()
|
||||
return os.getuid(), username
|
||||
|
||||
|
||||
def check_dependencies(code_repo_path: str, poetry_venvs_path: str):
|
||||
ERROR_MESSAGE = 'Please follow the instructions in https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md to install OpenHands.'
|
||||
if not os.path.exists(code_repo_path):
|
||||
@@ -63,28 +75,33 @@ def check_dependencies(code_repo_path: str, poetry_venvs_path: str):
|
||||
if 'jupyter' not in output.lower():
|
||||
raise ValueError('Jupyter is not properly installed. ' + ERROR_MESSAGE)
|
||||
|
||||
# Check libtmux is installed
|
||||
logger.debug('Checking dependencies: libtmux')
|
||||
import libtmux
|
||||
# Check libtmux is installed (skip on Windows)
|
||||
|
||||
server = libtmux.Server()
|
||||
try:
|
||||
session = server.new_session(session_name='test-session')
|
||||
except Exception:
|
||||
raise ValueError('tmux is not properly installed or available on the path.')
|
||||
pane = session.attached_pane
|
||||
pane.send_keys('echo "test"')
|
||||
pane_output = '\n'.join(pane.cmd('capture-pane', '-p').stdout)
|
||||
session.kill_session()
|
||||
if 'test' not in pane_output:
|
||||
raise ValueError('libtmux is not properly installed. ' + ERROR_MESSAGE)
|
||||
if sys.platform != 'win32':
|
||||
logger.debug('Checking dependencies: libtmux')
|
||||
import libtmux
|
||||
|
||||
# Check browser works
|
||||
logger.debug('Checking dependencies: browser')
|
||||
from openhands.runtime.browser.browser_env import BrowserEnv
|
||||
server = libtmux.Server()
|
||||
try:
|
||||
session = server.new_session(session_name='test-session')
|
||||
except Exception:
|
||||
raise ValueError('tmux is not properly installed or available on the path.')
|
||||
pane = session.attached_pane
|
||||
pane.send_keys('echo "test"')
|
||||
pane_output = '\n'.join(pane.cmd('capture-pane', '-p').stdout)
|
||||
session.kill_session()
|
||||
if 'test' not in pane_output:
|
||||
raise ValueError('libtmux is not properly installed. ' + ERROR_MESSAGE)
|
||||
|
||||
browser = BrowserEnv()
|
||||
browser.close()
|
||||
# Skip browser environment check on Windows
|
||||
if sys.platform != 'win32':
|
||||
logger.debug('Checking dependencies: browser')
|
||||
from openhands.runtime.browser.browser_env import BrowserEnv
|
||||
|
||||
browser = BrowserEnv()
|
||||
browser.close()
|
||||
else:
|
||||
logger.warning('Running on Windows - browser environment check skipped.')
|
||||
|
||||
|
||||
class LocalRuntime(ActionExecutionClient):
|
||||
@@ -110,9 +127,15 @@ class LocalRuntime(ActionExecutionClient):
|
||||
attach_to_existing: bool = False,
|
||||
headless_mode: bool = True,
|
||||
):
|
||||
self.is_windows = sys.platform == 'win32'
|
||||
if self.is_windows:
|
||||
logger.warning(
|
||||
'Running on Windows - some features that require tmux will be limited. '
|
||||
'For full functionality, please consider using WSL or Docker runtime.'
|
||||
)
|
||||
|
||||
self.config = config
|
||||
self._user_id = os.getuid()
|
||||
self._username = os.getenv('USER')
|
||||
self._user_id, self._username = get_user_info()
|
||||
|
||||
if self.config.workspace_base is not None:
|
||||
logger.warning(
|
||||
@@ -161,6 +184,7 @@ class LocalRuntime(ActionExecutionClient):
|
||||
self.status_callback = status_callback
|
||||
self.server_process: subprocess.Popen[str] | None = None
|
||||
self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
|
||||
self._log_thread_exit_event = threading.Event() # Add exit event
|
||||
|
||||
# Update env vars
|
||||
if self.config.sandbox.runtime_startup_env_vars:
|
||||
@@ -199,7 +223,7 @@ class LocalRuntime(ActionExecutionClient):
|
||||
server_port=self._host_port,
|
||||
plugins=self.plugins,
|
||||
app_config=self.config,
|
||||
python_prefix=[],
|
||||
python_prefix=['poetry', 'run'],
|
||||
override_user_id=self._user_id,
|
||||
override_username=self._username,
|
||||
)
|
||||
@@ -208,7 +232,7 @@ class LocalRuntime(ActionExecutionClient):
|
||||
env = os.environ.copy()
|
||||
# Get the code repo path
|
||||
code_repo_path = os.path.dirname(os.path.dirname(openhands.__file__))
|
||||
env['PYTHONPATH'] = f'{code_repo_path}{os.pathsep}{env.get("PYTHONPATH", "")}'
|
||||
env['PYTHONPATH'] = os.pathsep.join([code_repo_path, env.get('PYTHONPATH', '')])
|
||||
env['OPENHANDS_REPO_PATH'] = code_repo_path
|
||||
env['LOCAL_RUNTIME_MODE'] = '1'
|
||||
|
||||
@@ -230,19 +254,50 @@ class LocalRuntime(ActionExecutionClient):
|
||||
universal_newlines=True,
|
||||
bufsize=1,
|
||||
env=env,
|
||||
cwd=code_repo_path, # Explicitly set the working directory
|
||||
)
|
||||
|
||||
# Start a thread to read and log server output
|
||||
def log_output():
|
||||
while (
|
||||
self.server_process
|
||||
and self.server_process.poll()
|
||||
and self.server_process.stdout
|
||||
):
|
||||
line = self.server_process.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
self.log('debug', f'Server: {line.strip()}')
|
||||
if not self.server_process or not self.server_process.stdout:
|
||||
self.log('error', 'Server process or stdout not available for logging.')
|
||||
return
|
||||
|
||||
try:
|
||||
# Read lines while the process is running and stdout is available
|
||||
while self.server_process.poll() is None:
|
||||
if self._log_thread_exit_event.is_set(): # Check exit event
|
||||
self.log('info', 'Log thread received exit signal.')
|
||||
break # Exit loop if signaled
|
||||
line = self.server_process.stdout.readline()
|
||||
if not line:
|
||||
# Process might have exited between poll() and readline()
|
||||
break
|
||||
self.log('info', f'Server: {line.strip()}')
|
||||
|
||||
# Capture any remaining output after the process exits OR if signaled
|
||||
if (
|
||||
not self._log_thread_exit_event.is_set()
|
||||
): # Check again before reading remaining
|
||||
self.log('info', 'Server process exited, reading remaining output.')
|
||||
for line in self.server_process.stdout:
|
||||
if (
|
||||
self._log_thread_exit_event.is_set()
|
||||
): # Check inside loop too
|
||||
self.log(
|
||||
'info',
|
||||
'Log thread received exit signal while reading remaining output.',
|
||||
)
|
||||
break
|
||||
self.log('info', f'Server (remaining): {line.strip()}')
|
||||
|
||||
except Exception as e:
|
||||
# Log the error, but don't prevent the thread from potentially exiting
|
||||
self.log('error', f'Error reading server output: {e}')
|
||||
finally:
|
||||
self.log(
|
||||
'info', 'Log output thread finished.'
|
||||
) # Add log for thread exit
|
||||
|
||||
self._log_thread = threading.Thread(target=log_output, daemon=True)
|
||||
self._log_thread.start()
|
||||
@@ -312,6 +367,8 @@ class LocalRuntime(ActionExecutionClient):
|
||||
|
||||
def close(self):
|
||||
"""Stop the server process."""
|
||||
self._log_thread_exit_event.set() # Signal the log thread to exit
|
||||
|
||||
if self.server_process:
|
||||
self.server_process.terminate()
|
||||
try:
|
||||
@@ -319,7 +376,7 @@ class LocalRuntime(ActionExecutionClient):
|
||||
except subprocess.TimeoutExpired:
|
||||
self.server_process.kill()
|
||||
self.server_process = None
|
||||
self._log_thread.join()
|
||||
self._log_thread.join(timeout=5) # Add timeout to join
|
||||
|
||||
if self._temp_workspace:
|
||||
shutil.rmtree(self._temp_workspace)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -20,7 +23,7 @@ class JupyterPlugin(Plugin):
|
||||
name: str = 'jupyter'
|
||||
kernel_gateway_port: int
|
||||
kernel_id: str
|
||||
gateway_process: asyncio.subprocess.Process
|
||||
gateway_process: asyncio.subprocess.Process | subprocess.Popen
|
||||
python_interpreter_path: str
|
||||
|
||||
async def initialize(
|
||||
@@ -28,7 +31,10 @@ class JupyterPlugin(Plugin):
|
||||
) -> None:
|
||||
self.kernel_gateway_port = find_available_tcp_port(40000, 49999)
|
||||
self.kernel_id = kernel_id
|
||||
if username in ['root', 'openhands']:
|
||||
is_local_runtime = os.environ.get('LOCAL_RUNTIME_MODE') == '1'
|
||||
is_windows = sys.platform == 'win32'
|
||||
|
||||
if not is_local_runtime:
|
||||
# Non-LocalRuntime
|
||||
prefix = f'su - {username} -s '
|
||||
# cd to code repo, setup all env vars and run micromamba
|
||||
@@ -50,37 +56,84 @@ class JupyterPlugin(Plugin):
|
||||
)
|
||||
# The correct environment is ensured by the PATH in LocalRuntime.
|
||||
poetry_prefix = f'cd {code_repo_path}\n'
|
||||
jupyter_launch_command = (
|
||||
f"{prefix}/bin/bash << 'EOF'\n"
|
||||
f'{poetry_prefix}'
|
||||
'poetry run jupyter kernelgateway '
|
||||
'--KernelGatewayApp.ip=0.0.0.0 '
|
||||
f'--KernelGatewayApp.port={self.kernel_gateway_port}\n'
|
||||
'EOF'
|
||||
)
|
||||
logger.debug(f'Jupyter launch command: {jupyter_launch_command}')
|
||||
|
||||
# Using asyncio.create_subprocess_shell instead of subprocess.Popen
|
||||
# to avoid ASYNC101 linting error
|
||||
self.gateway_process = await asyncio.create_subprocess_shell(
|
||||
jupyter_launch_command,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
)
|
||||
# read stdout until the kernel gateway is ready
|
||||
output = ''
|
||||
while should_continue() and self.gateway_process.stdout is not None:
|
||||
line_bytes = await self.gateway_process.stdout.readline()
|
||||
line = line_bytes.decode('utf-8')
|
||||
output += line
|
||||
if 'at' in line:
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
logger.debug('Waiting for jupyter kernel gateway to start...')
|
||||
if is_windows:
|
||||
# Windows-specific command format
|
||||
jupyter_launch_command = (
|
||||
f'cd /d "{code_repo_path}" && '
|
||||
'poetry run jupyter kernelgateway '
|
||||
'--KernelGatewayApp.ip=0.0.0.0 '
|
||||
f'--KernelGatewayApp.port={self.kernel_gateway_port}'
|
||||
)
|
||||
logger.debug(f'Jupyter launch command (Windows): {jupyter_launch_command}')
|
||||
|
||||
# Using synchronous subprocess.Popen for Windows as asyncio.create_subprocess_shell
|
||||
# has limitations on Windows platforms
|
||||
self.gateway_process = subprocess.Popen( # type: ignore[ASYNC101] # noqa: ASYNC101
|
||||
jupyter_launch_command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
shell=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Windows-specific stdout handling with synchronous time.sleep
|
||||
# as asyncio has limitations on Windows for subprocess operations
|
||||
output = ''
|
||||
while should_continue():
|
||||
if self.gateway_process.stdout is None:
|
||||
time.sleep(1) # type: ignore[ASYNC101] # noqa: ASYNC101
|
||||
continue
|
||||
|
||||
line = self.gateway_process.stdout.readline()
|
||||
if not line:
|
||||
time.sleep(1) # type: ignore[ASYNC101] # noqa: ASYNC101
|
||||
continue
|
||||
|
||||
output += line
|
||||
if 'at' in line:
|
||||
break
|
||||
|
||||
time.sleep(1) # type: ignore[ASYNC101] # noqa: ASYNC101
|
||||
logger.debug('Waiting for jupyter kernel gateway to start...')
|
||||
|
||||
logger.debug(
|
||||
f'Jupyter kernel gateway started at port {self.kernel_gateway_port}. Output: {output}'
|
||||
)
|
||||
else:
|
||||
# Unix systems (Linux/macOS)
|
||||
jupyter_launch_command = (
|
||||
f"{prefix}/bin/bash << 'EOF'\n"
|
||||
f'{poetry_prefix}'
|
||||
'poetry run jupyter kernelgateway '
|
||||
'--KernelGatewayApp.ip=0.0.0.0 '
|
||||
f'--KernelGatewayApp.port={self.kernel_gateway_port}\n'
|
||||
'EOF'
|
||||
)
|
||||
logger.debug(f'Jupyter launch command: {jupyter_launch_command}')
|
||||
|
||||
# Using asyncio.create_subprocess_shell instead of subprocess.Popen
|
||||
# to avoid ASYNC101 linting error
|
||||
self.gateway_process = await asyncio.create_subprocess_shell(
|
||||
jupyter_launch_command,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
)
|
||||
# read stdout until the kernel gateway is ready
|
||||
output = ''
|
||||
while should_continue() and self.gateway_process.stdout is not None:
|
||||
line_bytes = await self.gateway_process.stdout.readline()
|
||||
line = line_bytes.decode('utf-8')
|
||||
output += line
|
||||
if 'at' in line:
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
logger.debug('Waiting for jupyter kernel gateway to start...')
|
||||
|
||||
logger.debug(
|
||||
f'Jupyter kernel gateway started at port {self.kernel_gateway_port}. Output: {output}'
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'Jupyter kernel gateway started at port {self.kernel_gateway_port}. Output: {output}'
|
||||
)
|
||||
_obs = await self.run(
|
||||
IPythonRunCellAction(code='import sys; print(sys.executable)')
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
@@ -32,6 +33,17 @@ def init_user_and_working_directory(
|
||||
Returns:
|
||||
int | None: The user ID if it was updated, None otherwise.
|
||||
"""
|
||||
# If running on Windows, just create the directory and return
|
||||
if sys.platform == 'win32':
|
||||
logger.debug('Running on Windows, skipping Unix-specific user setup')
|
||||
logger.debug(f'Client working directory: {initial_cwd}')
|
||||
|
||||
# Create the working directory if it doesn't exist
|
||||
os.makedirs(initial_cwd, exist_ok=True)
|
||||
logger.debug(f'Created working directory: {initial_cwd}')
|
||||
|
||||
return None
|
||||
|
||||
# if username is CURRENT_USER, then we don't need to do anything
|
||||
# This is specific to the local runtime
|
||||
if username == os.getenv('USER') and username not in ['root', 'openhands']:
|
||||
|
||||
1413
openhands/runtime/utils/windows_bash.py
Normal file
1413
openhands/runtime/utils/windows_bash.py
Normal file
File diff suppressed because it is too large
Load Diff
38
poetry.lock
generated
38
poetry.lock
generated
@@ -1457,6 +1457,21 @@ files = [
|
||||
{file = "cloudpickle-3.1.1.tar.gz", hash = "sha256:b216fa8ae4019d5482a8ac3c95d8f6346115d8835911fd4aefd1a445e4242c64"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clr-loader"
|
||||
version = "0.2.7.post0"
|
||||
description = "Generic pure Python loader for .NET runtimes"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "clr_loader-0.2.7.post0-py3-none-any.whl", hash = "sha256:e0b9fcc107d48347a4311a28ffe3ae78c4968edb216ffb6564cb03f7ace0bb47"},
|
||||
{file = "clr_loader-0.2.7.post0.tar.gz", hash = "sha256:b7a8b3f8fbb1bcbbb6382d887e21d1742d4f10b5ea209e4ad95568fe97e1c7c6"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
cffi = {version = ">=1.17", markers = "python_version >= \"3.8\""}
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
@@ -2856,7 +2871,7 @@ grpcio = {version = ">=1.49.1,<2.0dev", optional = true, markers = "python_versi
|
||||
grpcio-status = {version = ">=1.49.1,<2.0.dev0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
|
||||
proto-plus = [
|
||||
{version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0dev", markers = "python_version < \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0dev"},
|
||||
]
|
||||
protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0"
|
||||
requests = ">=2.18.0,<3.0.0.dev0"
|
||||
@@ -3071,7 +3086,7 @@ google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev"
|
||||
grpc-google-iam-v1 = ">=0.14.0,<1.0.0dev"
|
||||
proto-plus = [
|
||||
{version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0dev"},
|
||||
{version = ">=1.22.3,<2.0.0dev", markers = "python_version < \"3.13\""},
|
||||
]
|
||||
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev"
|
||||
|
||||
@@ -7663,6 +7678,21 @@ asyncio-client = ["aiohttp (>=3.4)"]
|
||||
client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"]
|
||||
docs = ["sphinx"]
|
||||
|
||||
[[package]]
|
||||
name = "pythonnet"
|
||||
version = "3.0.5"
|
||||
description = ".NET and Mono integration for Python"
|
||||
optional = false
|
||||
python-versions = "<3.14,>=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pythonnet-3.0.5-py3-none-any.whl", hash = "sha256:f6702d694d5d5b163c9f3f5cc34e0bed8d6857150237fae411fefb883a656d20"},
|
||||
{file = "pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
clr_loader = ">=0.2.7,<0.3.0"
|
||||
|
||||
[[package]]
|
||||
name = "pytz"
|
||||
version = "2025.1"
|
||||
@@ -11117,5 +11147,5 @@ cffi = ["cffi (>=1.11)"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "83ef9642a936252ac11e6625a68d833268a09be05a7670020e42068a9fc1f544"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "ff4b60b92f57d274444459e4376b65b77ca7efb174fea87082abff6cdb7fc6d5"
|
||||
|
||||
@@ -13,8 +13,8 @@ packages = [
|
||||
]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.12"
|
||||
litellm = "^1.60.0, !=1.64.4" # avoid 1.64.4 (known bug)
|
||||
python = "^3.12,<3.14"
|
||||
litellm = "^1.60.0, !=1.64.4, !=1.67.*" # avoid 1.64.4 (known bug) & 1.67.* (known bug #10272)
|
||||
aiohttp = ">=3.9.0,!=3.11.13" # Pin to avoid yanked version 3.11.13
|
||||
google-generativeai = "*" # To use litellm with Gemini Pro API
|
||||
google-api-python-client = "^2.164.0" # For Google Sheets API
|
||||
@@ -78,6 +78,7 @@ prompt-toolkit = "^3.0.50"
|
||||
mcpm = "1.9.0"
|
||||
poetry = "^2.1.2"
|
||||
anyio = "4.9.0"
|
||||
pythonnet = "*"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "0.11.8"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -194,6 +194,52 @@ def test_ipython_simple(temp_dir, runtime_cls):
|
||||
_close_test_runtime(runtime)
|
||||
|
||||
|
||||
def test_ipython_chdir(temp_dir, runtime_cls):
|
||||
"""Test that os.chdir correctly handles paths with slashes."""
|
||||
runtime, config = _load_runtime(temp_dir, runtime_cls)
|
||||
|
||||
# Create a test directory and get its absolute path
|
||||
test_code = """
|
||||
import os
|
||||
os.makedirs('test_dir', exist_ok=True)
|
||||
abs_path = os.path.abspath('test_dir')
|
||||
print(abs_path)
|
||||
"""
|
||||
action_ipython = IPythonRunCellAction(code=test_code)
|
||||
logger.info(action_ipython, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action_ipython)
|
||||
assert isinstance(obs, IPythonRunCellObservation)
|
||||
test_dir_path = obs.content.split('\n')[0].strip()
|
||||
logger.info(f'test_dir_path: {test_dir_path}')
|
||||
assert test_dir_path # Verify we got a valid path
|
||||
|
||||
# Change to the test directory using its absolute path
|
||||
test_code = f"""
|
||||
import os
|
||||
os.chdir(r'{test_dir_path}')
|
||||
print(os.getcwd())
|
||||
"""
|
||||
action_ipython = IPythonRunCellAction(code=test_code)
|
||||
logger.info(action_ipython, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action_ipython)
|
||||
assert isinstance(obs, IPythonRunCellObservation)
|
||||
current_dir = obs.content.split('\n')[0].strip()
|
||||
assert current_dir == test_dir_path # Verify we changed to the correct directory
|
||||
|
||||
# Clean up
|
||||
test_code = """
|
||||
import os
|
||||
import shutil
|
||||
shutil.rmtree('test_dir', ignore_errors=True)
|
||||
"""
|
||||
action_ipython = IPythonRunCellAction(code=test_code)
|
||||
logger.info(action_ipython, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action_ipython)
|
||||
assert isinstance(obs, IPythonRunCellObservation)
|
||||
|
||||
_close_test_runtime(runtime)
|
||||
|
||||
|
||||
def test_ipython_package_install(temp_dir, runtime_cls, run_as_openhands):
|
||||
"""Make sure that cd in bash also update the current working directory in ipython."""
|
||||
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
|
||||
|
||||
594
tests/unit/test_windows_bash.py
Normal file
594
tests/unit/test_windows_bash.py
Normal file
@@ -0,0 +1,594 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.events.action import CmdRunAction
|
||||
from openhands.events.observation import ErrorObservation
|
||||
from openhands.events.observation.commands import (
|
||||
CmdOutputObservation,
|
||||
)
|
||||
|
||||
# Skip all tests in this module if not running on Windows
|
||||
pytestmark = pytest.mark.skipif(
|
||||
sys.platform != 'win32', reason='WindowsPowershellSession tests require Windows'
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_work_dir():
|
||||
"""Create a temporary directory for testing."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
yield temp_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def windows_bash_session(temp_work_dir):
|
||||
"""Create a WindowsPowershellSession instance for testing."""
|
||||
# Instantiate the class. Initialization happens in __init__.
|
||||
session = WindowsPowershellSession(
|
||||
work_dir=temp_work_dir,
|
||||
username=None,
|
||||
)
|
||||
assert session._initialized # Should be true after __init__
|
||||
yield session
|
||||
# Ensure cleanup happens even if test fails
|
||||
session.close()
|
||||
|
||||
|
||||
if sys.platform == 'win32':
|
||||
from openhands.runtime.utils.windows_bash import WindowsPowershellSession
|
||||
|
||||
|
||||
def test_command_execution(windows_bash_session):
|
||||
"""Test basic command execution."""
|
||||
# Test a simple command
|
||||
action = CmdRunAction(command="Write-Output 'Hello World'")
|
||||
result = windows_bash_session.execute(action)
|
||||
|
||||
assert isinstance(result, CmdOutputObservation)
|
||||
# Check content, stripping potential trailing newlines
|
||||
content = result.content.strip()
|
||||
assert content == 'Hello World'
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Test a simple command with multiline input but single line output
|
||||
action = CmdRunAction(
|
||||
command="""Write-Output `
|
||||
('hello ' + `
|
||||
'world')"""
|
||||
)
|
||||
result = windows_bash_session.execute(action)
|
||||
|
||||
assert isinstance(result, CmdOutputObservation)
|
||||
# Check content, stripping potential trailing newlines
|
||||
content = result.content.strip()
|
||||
assert content == 'hello world'
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Test a simple command with a newline
|
||||
action = CmdRunAction(command='Write-Output "Hello\\n World"')
|
||||
result = windows_bash_session.execute(action)
|
||||
|
||||
assert isinstance(result, CmdOutputObservation)
|
||||
# Check content, stripping potential trailing newlines
|
||||
content = result.content.strip()
|
||||
assert content == 'Hello\\n World'
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_command_with_error(windows_bash_session):
|
||||
"""Test command execution with an error reported via Write-Error."""
|
||||
# Test a command that will write an error
|
||||
action = CmdRunAction(command="Write-Error 'Test Error'")
|
||||
result = windows_bash_session.execute(action)
|
||||
|
||||
assert isinstance(result, CmdOutputObservation)
|
||||
# Error stream is captured and appended
|
||||
assert 'ERROR' in result.content
|
||||
# Our implementation should set exit code to 1 when errors occur in stream
|
||||
assert result.exit_code == 1
|
||||
|
||||
|
||||
def test_command_failure_exit_code(windows_bash_session):
|
||||
"""Test command execution that results in a non-zero exit code."""
|
||||
# Test a command that causes a script failure (e.g., invalid cmdlet)
|
||||
action = CmdRunAction(command='Get-NonExistentCmdlet')
|
||||
result = windows_bash_session.execute(action)
|
||||
|
||||
assert isinstance(result, CmdOutputObservation)
|
||||
# Error should be captured in the output
|
||||
assert 'ERROR' in result.content
|
||||
assert (
|
||||
'is not recognized' in result.content
|
||||
or 'CommandNotFoundException' in result.content
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
|
||||
|
||||
def test_control_commands(windows_bash_session):
|
||||
"""Test handling of control commands (not supported)."""
|
||||
# Test Ctrl+C - should return ErrorObservation if no command is running
|
||||
action_c = CmdRunAction(command='C-c', is_input=True)
|
||||
result_c = windows_bash_session.execute(action_c)
|
||||
assert isinstance(result_c, ErrorObservation)
|
||||
assert 'No previous running command to interact with' in result_c.content
|
||||
|
||||
# Run a long-running command
|
||||
action_long_running = CmdRunAction(command='Start-Sleep -Seconds 100')
|
||||
result_long_running = windows_bash_session.execute(action_long_running)
|
||||
assert isinstance(result_long_running, CmdOutputObservation)
|
||||
assert result_long_running.exit_code == -1
|
||||
|
||||
# Test unsupported control command
|
||||
action_d = CmdRunAction(command='C-d', is_input=True)
|
||||
result_d = windows_bash_session.execute(action_d)
|
||||
assert "Your input command 'C-d' was NOT processed" in result_d.metadata.suffix
|
||||
assert (
|
||||
'Direct input to running processes (is_input=True) is not supported by this PowerShell session implementation.'
|
||||
in result_d.metadata.suffix
|
||||
)
|
||||
assert 'You can use C-c to stop the process' in result_d.metadata.suffix
|
||||
|
||||
# Ctrl+C now can cancel the long-running command
|
||||
action_c = CmdRunAction(command='C-c', is_input=True)
|
||||
result_c = windows_bash_session.execute(action_c)
|
||||
assert isinstance(result_c, CmdOutputObservation)
|
||||
assert result_c.exit_code == 0
|
||||
|
||||
|
||||
def test_command_timeout(windows_bash_session):
|
||||
"""Test command timeout handling."""
|
||||
# Test a command that will timeout
|
||||
test_timeout_sec = 1
|
||||
action = CmdRunAction(command='Start-Sleep -Seconds 5')
|
||||
action.set_hard_timeout(test_timeout_sec)
|
||||
start_time = time.monotonic()
|
||||
result = windows_bash_session.execute(action)
|
||||
duration = time.monotonic() - start_time
|
||||
|
||||
assert isinstance(result, CmdOutputObservation)
|
||||
# Check for timeout specific metadata
|
||||
assert 'timed out' in result.metadata.suffix.lower() # Check suffix, not content
|
||||
assert result.exit_code == -1 # Timeout should result in exit code -1
|
||||
# Check that it actually timed out near the specified time
|
||||
assert abs(duration - test_timeout_sec) < 0.5 # Allow some buffer
|
||||
|
||||
|
||||
def test_long_running_command(windows_bash_session):
|
||||
action = CmdRunAction(command='python -u -m http.server 8081')
|
||||
action.set_hard_timeout(1)
|
||||
result = windows_bash_session.execute(action)
|
||||
|
||||
assert isinstance(result, CmdOutputObservation)
|
||||
# Verify the initial output was captured
|
||||
assert 'Serving HTTP on' in result.content
|
||||
# Check for timeout specific metadata
|
||||
assert (
|
||||
"[The command timed out after 1.0 seconds. You may wait longer to see additional output by sending empty command '', send other commands to interact with the current process, or send keys to interrupt/kill the command.]"
|
||||
in result.metadata.suffix
|
||||
)
|
||||
assert result.exit_code == -1
|
||||
|
||||
# The action timed out, but the command should be still running
|
||||
# We should now be able to interrupt it
|
||||
action = CmdRunAction(command='C-c', is_input=True)
|
||||
action.set_hard_timeout(30) # Give it enough time to stop
|
||||
result = windows_bash_session.execute(action)
|
||||
|
||||
assert isinstance(result, CmdOutputObservation)
|
||||
# On Windows, Stop-Job termination doesn't inherently return output.
|
||||
# The CmdOutputObservation will have content="" and exit_code=0 if successful.
|
||||
# The KeyboardInterrupt message assertion is removed as it's added manually
|
||||
# by the wrapper and might not be guaranteed depending on timing/implementation details.
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Verify the server is actually stopped by starting another one on the same port
|
||||
action = CmdRunAction(command='python -u -m http.server 8081')
|
||||
action.set_hard_timeout(1) # Set a short timeout to check if it starts
|
||||
result = windows_bash_session.execute(action)
|
||||
|
||||
assert isinstance(result, CmdOutputObservation)
|
||||
# Verify the initial output was captured, indicating the port was free
|
||||
assert 'Serving HTTP on' in result.content
|
||||
# The command will time out again, so the exit code should be -1
|
||||
assert result.exit_code == -1
|
||||
|
||||
# Clean up the second server process
|
||||
action = CmdRunAction(command='C-c', is_input=True)
|
||||
action.set_hard_timeout(30)
|
||||
result = windows_bash_session.execute(action)
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_multiple_commands_rejected_and_individual_execution(windows_bash_session):
|
||||
"""Test that executing multiple commands separated by newline is rejected,
|
||||
but individual commands (including multiline) execute correctly."""
|
||||
# Define a list of commands, including multiline and special characters
|
||||
cmds = [
|
||||
'Get-ChildItem',
|
||||
'Write-Output "hello`nworld"',
|
||||
"""Write-Output "hello it's me\"""",
|
||||
"""Write-Output `
|
||||
'hello' `
|
||||
-NoNewline""",
|
||||
"""Write-Output 'hello`nworld`nare`nyou`nthere?'""",
|
||||
"""Write-Output 'hello`nworld`nare`nyou`n`nthere?'""",
|
||||
"""Write-Output 'hello`nworld `"'""", # Escape the trailing double quote
|
||||
]
|
||||
joined_cmds = '\n'.join(cmds)
|
||||
|
||||
# 1. Test that executing multiple commands at once fails
|
||||
action_multi = CmdRunAction(command=joined_cmds)
|
||||
result_multi = windows_bash_session.execute(action_multi)
|
||||
|
||||
assert isinstance(result_multi, ErrorObservation)
|
||||
assert 'ERROR: Cannot execute multiple commands at once' in result_multi.content
|
||||
|
||||
# 2. Now run each command individually and verify they work
|
||||
results = []
|
||||
for cmd in cmds:
|
||||
action_single = CmdRunAction(command=cmd)
|
||||
obs = windows_bash_session.execute(action_single)
|
||||
assert isinstance(obs, CmdOutputObservation)
|
||||
assert obs.exit_code == 0
|
||||
results.append(obs.content.strip()) # Strip trailing newlines for comparison
|
||||
|
||||
|
||||
def test_working_directory(windows_bash_session, temp_work_dir):
|
||||
"""Test working directory handling."""
|
||||
initial_cwd = windows_bash_session._cwd
|
||||
abs_temp_work_dir = os.path.abspath(temp_work_dir)
|
||||
assert initial_cwd == abs_temp_work_dir
|
||||
|
||||
# Create a subdirectory
|
||||
sub_dir_path = Path(abs_temp_work_dir) / 'subdir'
|
||||
sub_dir_path.mkdir()
|
||||
assert sub_dir_path.is_dir()
|
||||
|
||||
# Test changing directory
|
||||
action_cd = CmdRunAction(command='Set-Location subdir')
|
||||
result_cd = windows_bash_session.execute(action_cd)
|
||||
assert isinstance(result_cd, CmdOutputObservation)
|
||||
assert result_cd.exit_code == 0
|
||||
|
||||
# Check that the session's internal CWD state was updated - only check the last component of path
|
||||
assert windows_bash_session._cwd.lower().endswith('\\subdir')
|
||||
# Check that the metadata reflects the directory *after* the command
|
||||
assert result_cd.metadata.working_dir.lower().endswith('\\subdir')
|
||||
|
||||
# Execute a command in the new directory to confirm
|
||||
action_pwd = CmdRunAction(command='(Get-Location).Path')
|
||||
result_pwd = windows_bash_session.execute(action_pwd)
|
||||
assert isinstance(result_pwd, CmdOutputObservation)
|
||||
assert result_pwd.exit_code == 0
|
||||
# Check the command output reflects the new directory
|
||||
assert result_pwd.content.strip().lower().endswith('\\subdir')
|
||||
# Metadata should also reflect the current directory
|
||||
assert result_pwd.metadata.working_dir.lower().endswith('\\subdir')
|
||||
|
||||
# Test changing back to original directory
|
||||
action_cd_back = CmdRunAction(command=f"Set-Location '{abs_temp_work_dir}'")
|
||||
result_cd_back = windows_bash_session.execute(action_cd_back)
|
||||
assert isinstance(result_cd_back, CmdOutputObservation)
|
||||
assert result_cd_back.exit_code == 0
|
||||
# Check only the base name of the temp directory
|
||||
temp_dir_basename = os.path.basename(abs_temp_work_dir)
|
||||
assert windows_bash_session._cwd.lower().endswith(temp_dir_basename.lower())
|
||||
assert result_cd_back.metadata.working_dir.lower().endswith(
|
||||
temp_dir_basename.lower()
|
||||
)
|
||||
|
||||
|
||||
def test_cleanup(windows_bash_session):
|
||||
"""Test proper cleanup of resources (runspace)."""
|
||||
# Session should be initialized before close
|
||||
assert windows_bash_session._initialized
|
||||
assert windows_bash_session.runspace is not None
|
||||
|
||||
# Close the session
|
||||
windows_bash_session.close()
|
||||
|
||||
# Verify cleanup
|
||||
assert not windows_bash_session._initialized
|
||||
assert windows_bash_session.runspace is None
|
||||
assert windows_bash_session._closed
|
||||
|
||||
|
||||
def test_syntax_error_handling(windows_bash_session):
|
||||
"""Test handling of syntax errors in PowerShell commands."""
|
||||
# Test invalid command syntax
|
||||
action = CmdRunAction(command="Write-Output 'Missing Quote")
|
||||
result = windows_bash_session.execute(action)
|
||||
assert isinstance(result, ErrorObservation)
|
||||
# Error message appears in the output via PowerShell error stream
|
||||
assert 'missing' in result.content.lower() or 'terminator' in result.content.lower()
|
||||
|
||||
|
||||
def test_special_characters_handling(windows_bash_session):
|
||||
"""Test handling of commands containing special characters."""
|
||||
# Test command with special characters
|
||||
special_chars_cmd = '''Write-Output "Special Chars: \\`& \\`| \\`< \\`> \\`\\` \\`' \\`\" \\`! \\`$ \\`% \\`^ \\`( \\`) \\`- \\`= \\`+ \\`[ \\`] \\`{ \\`} \\`; \\`: \\`, \\`. \\`? \\`/ \\`~"'''
|
||||
action = CmdRunAction(command=special_chars_cmd)
|
||||
result = windows_bash_session.execute(action)
|
||||
assert isinstance(result, CmdOutputObservation)
|
||||
# Check output contains the special characters
|
||||
assert 'Special Chars:' in result.content
|
||||
assert '&' in result.content and '|' in result.content
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_empty_command(windows_bash_session):
|
||||
"""Test handling of empty command string when no command is running."""
|
||||
action = CmdRunAction(command='')
|
||||
result = windows_bash_session.execute(action)
|
||||
assert isinstance(result, CmdOutputObservation)
|
||||
# Should indicate error as per test_bash.py behavior
|
||||
assert 'ERROR: No previous running command to retrieve logs from.' in result.content
|
||||
# Exit code is typically 0 even for this specific "error" message in the bash implementation
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_exception_during_execution(windows_bash_session):
|
||||
"""Test handling of exceptions during command execution."""
|
||||
# Patch the PowerShell class itself within the module where it's used
|
||||
patch_target = 'openhands.runtime.utils.windows_bash.PowerShell'
|
||||
|
||||
# Create a mock PowerShell class
|
||||
mock_powershell_class = MagicMock()
|
||||
# Configure its Create method (which is called in execute) to raise an exception
|
||||
# This simulates an error during the creation of the PowerShell object itself.
|
||||
mock_powershell_class.Create.side_effect = Exception(
|
||||
'Test exception from mocked Create'
|
||||
)
|
||||
|
||||
with patch(patch_target, mock_powershell_class):
|
||||
action = CmdRunAction(command="Write-Output 'Test'")
|
||||
# Now, when execute calls PowerShell.Create(), it will hit our mock and raise the exception
|
||||
result = windows_bash_session.execute(action)
|
||||
|
||||
# The exception should be caught by the try...except block in execute()
|
||||
assert isinstance(result, ErrorObservation)
|
||||
# Check the error message generated by the execute method's exception handler
|
||||
assert 'Failed to start PowerShell job' in result.content
|
||||
assert 'Test exception from mocked Create' in result.content
|
||||
|
||||
|
||||
def test_streaming_output(windows_bash_session):
|
||||
"""Test handling of streaming output from commands."""
|
||||
# Command that produces output incrementally
|
||||
command = """
|
||||
1..3 | ForEach-Object {
|
||||
Write-Output "Line $_"
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
"""
|
||||
action = CmdRunAction(command=command)
|
||||
result = windows_bash_session.execute(action)
|
||||
|
||||
assert isinstance(result, CmdOutputObservation)
|
||||
assert 'Line 1' in result.content
|
||||
assert 'Line 2' in result.content
|
||||
assert 'Line 3' in result.content
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_shutdown_signal_handling(windows_bash_session):
|
||||
"""Test handling of shutdown signal during command execution."""
|
||||
# This would require mocking the shutdown_listener, which might be complex.
|
||||
# For now, we'll just verify that a long-running command can be executed
|
||||
# and that execute() returns properly.
|
||||
command = 'Start-Sleep -Seconds 1'
|
||||
action = CmdRunAction(command=command)
|
||||
result = windows_bash_session.execute(action)
|
||||
|
||||
assert isinstance(result, CmdOutputObservation)
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_runspace_state_after_error(windows_bash_session):
|
||||
"""Test that the runspace remains usable after a command error."""
|
||||
# First, execute a command with an error
|
||||
error_action = CmdRunAction(command='NonExistentCommand')
|
||||
error_result = windows_bash_session.execute(error_action)
|
||||
assert isinstance(error_result, CmdOutputObservation)
|
||||
assert error_result.exit_code == 1
|
||||
|
||||
# Then, execute a valid command
|
||||
valid_action = CmdRunAction(command="Write-Output 'Still working'")
|
||||
valid_result = windows_bash_session.execute(valid_action)
|
||||
assert isinstance(valid_result, CmdOutputObservation)
|
||||
assert 'Still working' in valid_result.content
|
||||
assert valid_result.exit_code == 0
|
||||
|
||||
|
||||
def test_stateful_file_operations(windows_bash_session, temp_work_dir):
|
||||
"""Test file operations to verify runspace state persistence.
|
||||
|
||||
This test verifies that:
|
||||
1. The working directory state persists between commands
|
||||
2. File operations work correctly relative to the current directory
|
||||
3. The runspace maintains state for path-dependent operations
|
||||
"""
|
||||
abs_temp_work_dir = os.path.abspath(temp_work_dir)
|
||||
|
||||
# 1. Create a subdirectory
|
||||
sub_dir_name = 'file_test_dir'
|
||||
sub_dir_path = Path(abs_temp_work_dir) / sub_dir_name
|
||||
|
||||
# Use PowerShell to create directory
|
||||
create_dir_action = CmdRunAction(
|
||||
command=f'New-Item -Path "{sub_dir_name}" -ItemType Directory'
|
||||
)
|
||||
result = windows_bash_session.execute(create_dir_action)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Verify directory exists on disk
|
||||
assert sub_dir_path.exists() and sub_dir_path.is_dir()
|
||||
|
||||
# 2. Change to the new directory
|
||||
cd_action = CmdRunAction(command=f"Set-Location '{sub_dir_name}'")
|
||||
result = windows_bash_session.execute(cd_action)
|
||||
assert result.exit_code == 0
|
||||
# Check only the last directory component
|
||||
assert windows_bash_session._cwd.lower().endswith(f'\\{sub_dir_name.lower()}')
|
||||
|
||||
# 3. Create a file in the current directory (which should be the subdirectory)
|
||||
test_content = 'This is a test file created by PowerShell'
|
||||
create_file_action = CmdRunAction(
|
||||
command=f'Set-Content -Path "test_file.txt" -Value "{test_content}"'
|
||||
)
|
||||
result = windows_bash_session.execute(create_file_action)
|
||||
assert result.exit_code == 0
|
||||
|
||||
# 4. Verify file exists at the expected path (in the subdirectory)
|
||||
expected_file_path = sub_dir_path / 'test_file.txt'
|
||||
assert expected_file_path.exists() and expected_file_path.is_file()
|
||||
|
||||
# 5. Read file contents using PowerShell and verify
|
||||
read_file_action = CmdRunAction(command='Get-Content -Path "test_file.txt"')
|
||||
result = windows_bash_session.execute(read_file_action)
|
||||
assert result.exit_code == 0
|
||||
assert test_content in result.content
|
||||
|
||||
# 6. Go back to parent and try to access file using relative path
|
||||
cd_parent_action = CmdRunAction(command='Set-Location ..')
|
||||
result = windows_bash_session.execute(cd_parent_action)
|
||||
assert result.exit_code == 0
|
||||
# Check only the base name of the temp directory
|
||||
temp_dir_basename = os.path.basename(abs_temp_work_dir)
|
||||
assert windows_bash_session._cwd.lower().endswith(temp_dir_basename.lower())
|
||||
|
||||
# 7. Read the file using relative path
|
||||
read_from_parent_action = CmdRunAction(
|
||||
command=f'Get-Content -Path "{sub_dir_name}/test_file.txt"'
|
||||
)
|
||||
result = windows_bash_session.execute(read_from_parent_action)
|
||||
assert result.exit_code == 0
|
||||
assert test_content in result.content
|
||||
|
||||
# 8. Clean up
|
||||
remove_file_action = CmdRunAction(
|
||||
command=f'Remove-Item -Path "{sub_dir_name}/test_file.txt" -Force'
|
||||
)
|
||||
result = windows_bash_session.execute(remove_file_action)
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_command_output_continuation(windows_bash_session):
|
||||
"""Test retrieving continued output using empty command after timeout."""
|
||||
# Windows PowerShell version
|
||||
action = CmdRunAction('1..5 | ForEach-Object { Write-Output $_; Start-Sleep 3 }')
|
||||
action.set_hard_timeout(2.5)
|
||||
obs = windows_bash_session.execute(action)
|
||||
assert obs.content.strip() == '1'
|
||||
assert obs.metadata.prefix == ''
|
||||
assert '[The command timed out after 2.5 seconds.' in obs.metadata.suffix
|
||||
|
||||
# Continue watching output
|
||||
action = CmdRunAction('')
|
||||
action.set_hard_timeout(2.5)
|
||||
obs = windows_bash_session.execute(action)
|
||||
assert '[Below is the output of the previous command.]' in obs.metadata.prefix
|
||||
assert obs.content.strip() == '2'
|
||||
assert '[The command timed out after 2.5 seconds.' in obs.metadata.suffix
|
||||
|
||||
# Continue until completion
|
||||
for expected in ['3', '4', '5']:
|
||||
action = CmdRunAction('')
|
||||
action.set_hard_timeout(2.5)
|
||||
obs = windows_bash_session.execute(action)
|
||||
assert '[Below is the output of the previous command.]' in obs.metadata.prefix
|
||||
assert obs.content.strip() == expected
|
||||
assert '[The command timed out after 2.5 seconds.' in obs.metadata.suffix
|
||||
|
||||
# Final empty command to complete
|
||||
action = CmdRunAction('')
|
||||
obs = windows_bash_session.execute(action)
|
||||
assert '[The command completed with exit code 0.]' in obs.metadata.suffix
|
||||
|
||||
|
||||
def test_long_running_command_followed_by_execute(windows_bash_session):
|
||||
"""Tests behavior when a new command is sent while another is running after timeout."""
|
||||
# Start a slow command
|
||||
action = CmdRunAction('1..3 | ForEach-Object { Write-Output $_; Start-Sleep 3 }')
|
||||
action.set_hard_timeout(2.5)
|
||||
obs = windows_bash_session.execute(action)
|
||||
assert '1' in obs.content # First number should appear before timeout
|
||||
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
|
||||
assert '[The command timed out after 2.5 seconds.' in obs.metadata.suffix
|
||||
assert obs.metadata.prefix == ''
|
||||
|
||||
# Continue watching output
|
||||
action = CmdRunAction('')
|
||||
action.set_hard_timeout(2.5)
|
||||
obs = windows_bash_session.execute(action)
|
||||
assert '2' in obs.content
|
||||
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
|
||||
assert '[The command timed out after 2.5 seconds.' in obs.metadata.suffix
|
||||
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
|
||||
|
||||
# Test command that produces no output
|
||||
action = CmdRunAction('sleep 15')
|
||||
action.set_hard_timeout(2.5)
|
||||
obs = windows_bash_session.execute(action)
|
||||
assert '3' not in obs.content
|
||||
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
|
||||
assert 'The previous command is still running' in obs.metadata.suffix
|
||||
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
|
||||
|
||||
# Finally continue again
|
||||
action = CmdRunAction('')
|
||||
obs = windows_bash_session.execute(action)
|
||||
assert '3' in obs.content
|
||||
assert '[The command completed with exit code 0.]' in obs.metadata.suffix
|
||||
|
||||
|
||||
def test_command_non_existent_file(windows_bash_session):
|
||||
"""Test command execution for a non-existent file returns non-zero exit code."""
|
||||
# Use Get-Content which should fail if the file doesn't exist
|
||||
action = CmdRunAction(command='Get-Content non_existent_file.txt')
|
||||
result = windows_bash_session.execute(action)
|
||||
|
||||
assert isinstance(result, CmdOutputObservation)
|
||||
# Check that the exit code is non-zero (should be 1 due to the '$?' check)
|
||||
assert result.exit_code == 1
|
||||
# Check that the error message is captured in the output (error stream part)
|
||||
assert 'Cannot find path' in result.content or 'does not exist' in result.content
|
||||
|
||||
|
||||
def test_interactive_input(windows_bash_session):
|
||||
"""Test interactive input attempt reflects implementation limitations."""
|
||||
action = CmdRunAction('$name = Read-Host "Enter name"')
|
||||
result = windows_bash_session.execute(action)
|
||||
|
||||
assert isinstance(result, CmdOutputObservation)
|
||||
assert (
|
||||
'A command that prompts the user failed because the host program or the command type does not support user interaction. The host was attempting to request confirmation with the following message'
|
||||
in result.content
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
|
||||
|
||||
def test_windows_path_handling(windows_bash_session, temp_work_dir):
|
||||
"""Test that os.chdir works with both forward slashes and escaped backslashes on Windows."""
|
||||
# Create a test directory
|
||||
test_dir = Path(temp_work_dir) / 'test_dir'
|
||||
test_dir.mkdir()
|
||||
|
||||
# Test both path formats
|
||||
path_formats = [
|
||||
str(test_dir).replace('\\', '/'), # Forward slashes
|
||||
str(test_dir).replace('\\', '\\\\'), # Escaped backslashes
|
||||
]
|
||||
|
||||
for path in path_formats:
|
||||
# Test changing directory using os.chdir through PowerShell
|
||||
action = CmdRunAction(command=f'python -c "import os; os.chdir(\'{path}\')"')
|
||||
result = windows_bash_session.execute(action)
|
||||
assert isinstance(result, CmdOutputObservation)
|
||||
assert result.exit_code == 0, f'Failed with path format: {path}'
|
||||
Reference in New Issue
Block a user