diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 03662ac..42aa818 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,28 +1,10 @@ repos: - - repo: https://github.com/pycqa/flake8.git - rev: 7.1.1 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.4 hooks: - - id: flake8 - exclude: | - (?x)^( - examples/| - docs/| - tests/ - ) - - repo: https://github.com/PyCQA/isort.git - rev: 6.0.1 - hooks: - - id: isort - - repo: https://github.com/google/yapf - rev: v0.43.0 - hooks: - - id: yapf - exclude: | - (?x)^( - examples/| - docs/| - tests/ - ) + - id: ruff-check + args: [--fix] + - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks.git rev: v5.0.0 hooks: @@ -35,8 +17,6 @@ repos: exclude: thirdparty/|docs/ - id: requirements-txt-fixer exclude: thirdparty/ - - id: double-quote-string-fixer - exclude: thirdparty/ - id: check-merge-conflict exclude: thirdparty/ - id: mixed-line-ending diff --git a/ms_enclave/cli/start_server.py b/ms_enclave/cli/start_server.py index 0fab65e..36252b1 100644 --- a/ms_enclave/cli/start_server.py +++ b/ms_enclave/cli/start_server.py @@ -10,8 +10,7 @@ def subparser_func(args): - """ Function which will be called for a specific sub parser. - """ + """Function which will be called for a specific sub parser.""" return ServerCMD(args) @@ -23,8 +22,7 @@ def __init__(self, args): @staticmethod def define_args(parsers: ArgumentParser): - """Define args for the server command. - """ + """Define args for the server command.""" parser = parsers.add_parser(ServerCMD.name, help='Start the MS-Enclave sandbox HTTP server') add_argument(parser) parser.set_defaults(func=subparser_func) @@ -67,18 +65,18 @@ def add_argument(parser: ArgumentParser) -> None: type=str, choices=['critical', 'error', 'warning', 'info', 'debug'], default='info', - help='Log level for the server (default: info)' + help='Log level for the server (default: info)', ) parser.add_argument( '--cleanup-interval', type=int, default=300, metavar='SECONDS', - help='Background cleanup interval in seconds (default: 300)' + help='Background cleanup interval in seconds (default: 300)', ) parser.add_argument( '--api-key', type=str, default=None, - help='Optional API key to protect endpoints. If omitted, no authentication is enforced.' + help='Optional API key to protect endpoints. If omitted, no authentication is enforced.', ) diff --git a/ms_enclave/sandbox/boxes/__init__.py b/ms_enclave/sandbox/boxes/__init__.py index 988f88b..d0c83d5 100644 --- a/ms_enclave/sandbox/boxes/__init__.py +++ b/ms_enclave/sandbox/boxes/__init__.py @@ -12,7 +12,6 @@ 'SandboxFactory', 'register_sandbox', 'StatelessSandbox', - # Implementations 'DockerSandbox', 'DockerNotebookSandbox', diff --git a/ms_enclave/sandbox/boxes/base.py b/ms_enclave/sandbox/boxes/base.py index 2318b8f..ec7e67c 100644 --- a/ms_enclave/sandbox/boxes/base.py +++ b/ms_enclave/sandbox/boxes/base.py @@ -206,7 +206,7 @@ def get_info(self) -> SandboxInfo: created_at=self.created_at, updated_at=self.updated_at, metadata=self.metadata, - available_tools=self.get_available_tools() + available_tools=self.get_available_tools(), ) async def __aenter__(self): @@ -239,7 +239,7 @@ def create_sandbox( cls, sandbox_type: SandboxType, config: Optional[Union[SandboxConfig, Dict]] = None, - sandbox_id: Optional[str] = None + sandbox_id: Optional[str] = None, ) -> Sandbox: """Create a sandbox instance. diff --git a/ms_enclave/sandbox/boxes/docker_notebook.py b/ms_enclave/sandbox/boxes/docker_notebook.py index 5212358..28957a4 100644 --- a/ms_enclave/sandbox/boxes/docker_notebook.py +++ b/ms_enclave/sandbox/boxes/docker_notebook.py @@ -58,6 +58,7 @@ async def start(self) -> None: # Initialize Docker client first import docker + self.client = docker.from_env() # Build Jupyter image if needed before creating container @@ -161,9 +162,9 @@ def build_image(): # Process and log build output for log in build_logs[1]: # build_logs[1] contains the build log generator if 'stream' in log: - logger.info(f"[📦 {self.id}] {log['stream'].strip()}") + logger.info(f'[📦 {self.id}] {log["stream"].strip()}') elif 'error' in log: - logger.error(f"[📦 {self.id}] {log['error']}") + logger.error(f'[📦 {self.id}] {log["error"]}') return build_logs[0] # Return the built image await self._run_blocking(build_image) @@ -191,34 +192,50 @@ async def _create_kernel(self) -> None: # Establish websocket connection try: from websocket import create_connection + ws_url = f'ws://{self.host}:{self.port}/api/kernels/{self.kernel_id}/channels' self.ws = create_connection(ws_url) logger.info(f'Kernel {self.kernel_id} created and connected') except ImportError: raise RuntimeError('websocket-client package is required. Install with: pip install websocket-client') + def _clear_jupyter_session(self) -> None: + """Drop local references to the current Jupyter session.""" + if self.ws: + try: + self.ws.close() + except Exception: + pass + self.ws = None + self.kernel_id = None + self.base_url = None + + async def _reset_after_interrupted_exec(self) -> None: + """Restart the container and recreate the Jupyter session.""" + self.update_status(SandboxStatus.INITIALIZING) + self._clear_jupyter_session() + try: + await self._restart_container() + await self._setup_jupyter() + except BaseException as exc: + self.update_status(SandboxStatus.ERROR) + self.metadata['error'] = str(exc) + logger.error(f'Failed to reset notebook sandbox after interrupted exec: {exc}') + raise + self.update_status(SandboxStatus.RUNNING) + async def cleanup(self) -> None: """Clean up Jupyter resources and Docker container.""" - try: - # Close websocket connection - if self.ws: - try: - self.ws.close() - except Exception: - pass - self.ws = None - - # Delete kernel - if self.kernel_id and self.base_url: - try: - import requests - requests.delete(f'{self.base_url}/api/kernels/{self.kernel_id}') - except Exception: - pass - self.kernel_id = None + kernel_id = self.kernel_id + base_url = self.base_url + self._clear_jupyter_session() - except Exception as e: - logger.error(f'Error during Jupyter cleanup: {e}') + if kernel_id and base_url: + try: + import requests + + requests.delete(f'{base_url}/api/kernels/{kernel_id}') + except Exception: + pass - # Call parent cleanup await super().cleanup() diff --git a/ms_enclave/sandbox/boxes/docker_sandbox.py b/ms_enclave/sandbox/boxes/docker_sandbox.py index b1ce44b..2c1b8f6 100644 --- a/ms_enclave/sandbox/boxes/docker_sandbox.py +++ b/ms_enclave/sandbox/boxes/docker_sandbox.py @@ -165,13 +165,7 @@ async def put_archive(self, target_dir: str, data: bytes) -> bool: return bool(await self._run_blocking(self.container.put_archive, target_dir, data)) async def _aiter_exec_output(self, exec_id: str) -> AsyncIterator[Tuple[Optional[bytes], Optional[bytes]]]: - """Bridge ``exec_start(stream=True)`` chunks from a worker thread to async. - - The producer thread iterates the blocking docker generator and pushes each - (stdout, stderr) tuple onto an unbounded ``asyncio.Queue``. The async caller - consumes the queue. When the caller stops iterating (cancel/timeout), it is - responsible for invoking ``exec_kill`` so the producer's iterator unblocks. - """ + """Bridge ``exec_start(stream=True)`` chunks from a worker thread to async.""" queue: asyncio.Queue = asyncio.Queue() loop = asyncio.get_running_loop() @@ -194,57 +188,36 @@ def _producer(): raise item yield item - async def _kill_exec_safe(self, exec_id: str) -> None: - """Best-effort termination of the exec process and its children.""" - pid = None - try: - inspect = await self._run_blocking(self.client.api.exec_inspect, exec_id) - pid = inspect.get('Pid') - except Exception as e: - logger.debug(f'exec_inspect failed before exec_kill: {e}') - - if pid and self.container: - kill_tree_cmd = ( - 'kill_tree() { ' - 'for child in $(cat /proc/$1/task/$1/children 2>/dev/null); do ' - 'kill_tree "$child" "$2"; ' - 'done; ' - 'kill "$2" "$1" 2>/dev/null || true; ' - '}; ' - f'kill_tree {pid} -TERM; ' - 'sleep 0.2; ' - f'kill_tree {pid} -KILL' - ) - try: - await self._run_blocking(self.container.exec_run, ['sh', '-c', kill_tree_cmd]) - except Exception as e: - logger.debug(f'exec process-tree kill failed: {e}') - - try: - await self._run_blocking(self.client.api.exec_kill, exec_id, 'SIGKILL') - except Exception as e: - logger.debug(f'exec_kill failed (likely already finished): {e}') - - def _run_buffered(self, command: Union[str, List[str]]) -> Tuple[int, str, str]: - """Execute command and return buffered output using high-level API. + def _start_exec_buffered(self, exec_id: str) -> Tuple[str, str]: + """Start an exec and return its buffered output.""" + output = self.client.api.exec_start(exec_id, stream=False, demux=True) + if isinstance(output, tuple): + stdout, stderr = output + else: + stdout, stderr = output, b'' + return ( + stdout.decode('utf-8', errors='replace') if stdout else '', + stderr.decode('utf-8', errors='replace') if stderr else '', + ) - Returns: - A tuple of (exit_code, stdout, stderr) - """ - if not self.container: + async def _restart_container(self) -> None: + """Kill and restart the container, waiting until it is ready.""" + if not self.container or not self.client: raise RuntimeError('Container is not running') + await self._run_blocking(self.container.kill) + await self._start_container() - res = self.container.exec_run(command, tty=False, stream=False, demux=True) - out_tuple = res.output - if isinstance(out_tuple, tuple): - out_bytes, err_bytes = out_tuple - else: - # Fallback: when demux was not honored, treat all as stdout - out_bytes, err_bytes = out_tuple, b'' - - stdout = out_bytes.decode('utf-8', errors='replace') if out_bytes else '' - stderr = err_bytes.decode('utf-8', errors='replace') if err_bytes else '' - return res.exit_code, stdout, stderr + async def _reset_after_interrupted_exec(self) -> None: + """Restore a clean sandbox after an interrupted Docker exec.""" + self.update_status(SandboxStatus.INITIALIZING) + try: + await self._restart_container() + except BaseException as exc: + self.update_status(SandboxStatus.ERROR) + self.metadata['error'] = str(exc) + logger.error(f'Failed to reset sandbox after interrupted exec: {exc}') + raise + self.update_status(SandboxStatus.RUNNING) @staticmethod def _wrap_command_timeout(command: Union[str, List[str]], timeout: Optional[int]) -> Union[str, List[str]]: @@ -293,6 +266,8 @@ async def execute_command( Returns: CommandResult with status, exit_code, stdout and stderr """ + if self.status == SandboxStatus.ERROR: + raise RuntimeError('Sandbox is in error state') if not self.container or not self.client: raise RuntimeError('Container is not running') @@ -300,40 +275,20 @@ async def execute_command( exec_command = self._wrap_command_timeout(command, timeout) wait_timeout = self._outer_timeout(timeout) - if not stream: + exec_creation = asyncio.create_task( + self._run_blocking(self.client.api.exec_create, container=self.container.id, cmd=exec_command, tty=False) + ) + try: + exec_meta = await asyncio.shield(exec_creation) + except asyncio.CancelledError: try: - started_at = time.monotonic() - exit_code, stdout, stderr = await asyncio.wait_for( - self._run_blocking(self._run_buffered, exec_command), timeout=wait_timeout - ) - if self._is_timeout_exit(exit_code, timeout, started_at): - status = ExecutionStatus.TIMEOUT - exit_code = -1 - stderr = stderr or f'Command timed out after {timeout} seconds' - else: - status = ExecutionStatus.SUCCESS if exit_code == 0 else ExecutionStatus.ERROR - return CommandResult( - command=original_command, status=status, exit_code=exit_code, stdout=stdout, stderr=stderr - ) - except asyncio.TimeoutError: - return CommandResult( - command=original_command, - status=ExecutionStatus.TIMEOUT, - exit_code=-1, - stdout='', - stderr=f'Command timed out after {timeout} seconds', - ) - except Exception as e: - return CommandResult( - command=original_command, status=ExecutionStatus.ERROR, exit_code=-1, stdout='', stderr=str(e) - ) + await asyncio.shield(exec_creation) + except Exception: + pass + await self._reset_after_interrupted_exec() + raise - # Streaming path: keep exec_id so we can exec_kill on cancel/timeout. - exec_meta = await self._run_blocking( - self.client.api.exec_create, container=self.container.id, cmd=exec_command, tty=False - ) exec_id = exec_meta['Id'] - stdout_parts: List[str] = [] stderr_parts: List[str] = [] @@ -352,7 +307,15 @@ async def _consume() -> None: try: started_at = time.monotonic() - await asyncio.wait_for(_consume(), timeout=wait_timeout) + if stream: + await asyncio.wait_for(_consume(), timeout=wait_timeout) + else: + stdout, stderr = await asyncio.wait_for( + self._run_blocking(self._start_exec_buffered, exec_id), timeout=wait_timeout + ) + stdout_parts.append(stdout) + stderr_parts.append(stderr) + inspect = await self._run_blocking(self.client.api.exec_inspect, exec_id) exit_code = inspect.get('ExitCode') if exit_code is None: @@ -372,7 +335,7 @@ async def _consume() -> None: stderr=stderr, ) except asyncio.TimeoutError: - await self._kill_exec_safe(exec_id) + await self._reset_after_interrupted_exec() return CommandResult( command=original_command, status=ExecutionStatus.TIMEOUT, @@ -381,10 +344,10 @@ async def _consume() -> None: stderr=f'Command timed out after {timeout} seconds', ) except asyncio.CancelledError: - await self._kill_exec_safe(exec_id) + await self._reset_after_interrupted_exec() raise except Exception as e: - await self._kill_exec_safe(exec_id) + await self._reset_after_interrupted_exec() return CommandResult( command=original_command, status=ExecutionStatus.ERROR, diff --git a/ms_enclave/sandbox/boxes/volcengine.py b/ms_enclave/sandbox/boxes/volcengine.py index f7c3bc1..afb5a7d 100644 --- a/ms_enclave/sandbox/boxes/volcengine.py +++ b/ms_enclave/sandbox/boxes/volcengine.py @@ -56,7 +56,9 @@ def __init__( _verify_ssl = verify_ssl if verify_ssl is not None else config.verify_ssl _extra_headers = extra_headers if extra_headers is not None else config.extra_headers _api_key = api_key if api_key is not None else config.api_key - _dataset_language_map = dataset_language_map if dataset_language_map is not None else config.dataset_language_map + _dataset_language_map = ( + dataset_language_map if dataset_language_map is not None else config.dataset_language_map + ) super().__init__( config, @@ -101,9 +103,7 @@ async def run_code( Applies ``dataset_language_map`` to translate language identifiers before sending, so e.g. ``r`` can be sent upstream as ``R``. """ - mapped_language = ( - self._dataset_language_map.get(language, language) if self._dataset_language_map else language - ) + mapped_language = self._dataset_language_map.get(language, language) if self._dataset_language_map else language payload = {'code': code, 'language': mapped_language} return await self._post_json(self._run_code_path, payload, timeout=timeout) diff --git a/ms_enclave/sandbox/manager/base.py b/ms_enclave/sandbox/manager/base.py index c555885..5c636b4 100644 --- a/ms_enclave/sandbox/manager/base.py +++ b/ms_enclave/sandbox/manager/base.py @@ -51,7 +51,7 @@ async def create_sandbox( self, sandbox_type: SandboxType, config: Optional[Union[SandboxConfig, Dict]] = None, - sandbox_id: Optional[str] = None + sandbox_id: Optional[str] = None, ) -> str: """Create a new sandbox. @@ -180,7 +180,7 @@ async def initialize_pool( self, pool_size: Optional[int] = None, sandbox_type: Optional[SandboxType] = None, - config: Optional[Union[SandboxConfig, Dict]] = None + config: Optional[Union[SandboxConfig, Dict]] = None, ) -> List[str]: """Initialize sandbox pool. @@ -248,7 +248,7 @@ def create_manager( cls, manager_type: Optional[SandboxManagerType] = None, config: Optional[SandboxManagerConfig] = None, - **kwargs: Any + **kwargs: Any, ) -> SandboxManager: """Create a sandbox manager instance. @@ -272,8 +272,7 @@ def create_manager( if manager_type not in cls._registry: raise ValueError( - f"Sandbox manager type '{manager_type}' not registered. " - f'Available types: {list(cls._registry.keys())}' + f"Sandbox manager type '{manager_type}' not registered. Available types: {list(cls._registry.keys())}" ) manager_class = cls._registry[manager_type] diff --git a/ms_enclave/sandbox/manager/http_manager.py b/ms_enclave/sandbox/manager/http_manager.py index 3c62945..a7bdd6e 100644 --- a/ms_enclave/sandbox/manager/http_manager.py +++ b/ms_enclave/sandbox/manager/http_manager.py @@ -26,8 +26,7 @@ @register_manager(SandboxManagerType.HTTP) class HttpSandboxManager(SandboxManager): - """HTTP-based sandbox manager for remote services. - """ + """HTTP-based sandbox manager for remote services.""" def __init__(self, config: Optional[SandboxManagerConfig] = None, **kwargs): """Initialize HTTP sandbox manager. @@ -43,7 +42,7 @@ def __init__(self, config: Optional[SandboxManagerConfig] = None, **kwargs): self.timeout = aiohttp.ClientTimeout(total=self.config.timeout or kwargs.get('timeout')) self.api_key = self.config.api_key or kwargs.get('api_key') - self._default_headers: Optional[Dict[str, str]] = ({'X-API-Key': self.api_key} if self.api_key else None) + self._default_headers: Optional[Dict[str, str]] = {'X-API-Key': self.api_key} if self.api_key else None self._session: Optional[aiohttp.ClientSession] = None async def start(self) -> None: @@ -79,7 +78,7 @@ async def create_sandbox( self, sandbox_type: SandboxType, config: Optional[Union[SandboxConfig, Dict]] = None, - sandbox_id: Optional[str] = None + sandbox_id: Optional[str] = None, ) -> str: """Create a new sandbox via HTTP API. @@ -122,7 +121,7 @@ async def create_sandbox( return sandbox_id else: error_data = await response.json() - raise RuntimeError(f"HTTP {response.status}: {error_data.get('detail', 'Unknown error')}") + raise RuntimeError(f'HTTP {response.status}: {error_data.get("detail", "Unknown error")}') except aiohttp.ClientError as e: logger.error(f'HTTP client error creating sandbox: {e}') @@ -287,7 +286,7 @@ async def execute_tool(self, sandbox_id: str, tool_name: str, parameters: Dict[s raise RuntimeError(error_data.get('detail', 'Internal server error')) else: error_data = await response.json() - raise RuntimeError(f"HTTP {response.status}: {error_data.get('detail', 'Unknown error')}") + raise RuntimeError(f'HTTP {response.status}: {error_data.get("detail", "Unknown error")}') except aiohttp.ClientError as e: logger.error(f'HTTP client error executing tool: {e}') @@ -319,7 +318,7 @@ async def get_sandbox_tools(self, sandbox_id: str) -> Dict[str, Any]: raise ValueError(error_data.get('detail', f'Sandbox {sandbox_id} not found')) else: error_data = await response.json() - raise RuntimeError(f"HTTP {response.status}: {error_data.get('detail', 'Unknown error')}") + raise RuntimeError(f'HTTP {response.status}: {error_data.get("detail", "Unknown error")}') except aiohttp.ClientError as e: logger.error(f'HTTP client error getting sandbox tools: {e}') @@ -405,6 +404,7 @@ async def get_stats(self) -> Dict[str, Any]: # Add local tracking stats from collections import Counter + status_counter = Counter() type_counter = Counter() @@ -434,7 +434,7 @@ async def get_stats(self) -> Dict[str, Any]: 'base_url': self.base_url, 'tracked_sandboxes': len(self._sandboxes), 'running': self._running, - 'error': str(e) + 'error': str(e), } async def health_check(self) -> Dict[str, Any]: @@ -464,7 +464,7 @@ async def initialize_pool( self, pool_size: Optional[int] = None, sandbox_type: Optional[SandboxType] = None, - config: Optional[Union[SandboxConfig, Dict]] = None + config: Optional[Union[SandboxConfig, Dict]] = None, ) -> List[str]: """Initialize sandbox pool via HTTP API. @@ -524,7 +524,7 @@ async def initialize_pool( return sandbox_ids else: error_data = await response.json() - raise RuntimeError(f"HTTP {response.status}: {error_data.get('detail', 'Unknown error')}") + raise RuntimeError(f'HTTP {response.status}: {error_data.get("detail", "Unknown error")}') except aiohttp.ClientError as e: logger.error(f'HTTP client error initializing pool: {e}') @@ -579,7 +579,7 @@ async def execute_tool_in_pool( raise TimeoutError(error_data.get('detail', 'Timeout waiting for available sandbox')) else: error_data = await response.json() - raise RuntimeError(f"HTTP {response.status}: {error_data.get('detail', 'Unknown error')}") + raise RuntimeError(f'HTTP {response.status}: {error_data.get("detail", "Unknown error")}') except aiohttp.ClientError as e: logger.error(f'HTTP client error executing tool in pool: {e}') diff --git a/ms_enclave/sandbox/manager/local_manager.py b/ms_enclave/sandbox/manager/local_manager.py index 7138570..c4b5a3a 100644 --- a/ms_enclave/sandbox/manager/local_manager.py +++ b/ms_enclave/sandbox/manager/local_manager.py @@ -38,6 +38,7 @@ def __init__(self, config: Optional[SandboxManagerConfig] = None, **kwargs): self._cleanup_interval = self.config.cleanup_interval or kwargs.get('cleanup_interval', 300) self._cleanup_task: Optional[asyncio.Task] = None self._pool_condition: Optional[asyncio.Condition] = None + self._sandbox_locks: Dict[str, asyncio.Lock] = {} async def start(self) -> None: """Start the sandbox manager.""" @@ -87,7 +88,7 @@ async def create_sandbox( self, sandbox_type: SandboxType, config: Optional[Union[SandboxConfig, Dict]] = None, - sandbox_id: Optional[str] = None + sandbox_id: Optional[str] = None, ) -> str: """Create a new sandbox. @@ -231,9 +232,10 @@ async def execute_tool(self, sandbox_id: str, tool_name: str, parameters: Dict[s Raises: ValueError: If sandbox or tool not found """ - sandbox = self._get_running_sandbox(sandbox_id) - result = await sandbox.execute_tool(tool_name, parameters) - return result + lock = self._sandbox_locks.setdefault(sandbox_id, asyncio.Lock()) + async with lock: + sandbox = self._get_running_sandbox(sandbox_id) + return await sandbox.execute_tool(tool_name, parameters) async def get_sandbox_tools(self, sandbox_id: str) -> Dict[str, Any]: """Get available tools for a sandbox. @@ -343,7 +345,7 @@ async def initialize_pool( self, pool_size: Optional[int] = None, sandbox_type: Optional[SandboxType] = None, - config: Optional[Union[SandboxConfig, Dict]] = None + config: Optional[Union[SandboxConfig, Dict]] = None, ) -> List[str]: """Initialize sandbox pool. @@ -458,16 +460,14 @@ async def execute_tool_in_pool( raise TimeoutError(f'Timeout waiting for available sandbox from pool after {timeout}s') # Execute tool outside of condition lock + sandbox = self._sandboxes[sandbox_id] try: - sandbox = self._sandboxes[sandbox_id] - result = await sandbox.execute_tool(tool_name, parameters) - return result + return await self.execute_tool(sandbox_id, tool_name, parameters) finally: - # Return to pool and notify waiting tasks async with self._pool_condition: - sandbox.status = SandboxStatus.IDLE - sandbox.updated_at = datetime.now() - self._sandbox_pool.append(sandbox_id) - logger.debug(f'Returned sandbox {sandbox_id} to pool') - # Notify one waiting task that a sandbox is available + if sandbox.status != SandboxStatus.ERROR: + sandbox.status = SandboxStatus.IDLE + sandbox.updated_at = datetime.now() + self._sandbox_pool.append(sandbox_id) + logger.debug(f'Returned sandbox {sandbox_id} to pool') self._pool_condition.notify(1) diff --git a/ms_enclave/sandbox/manager/volcengine.py b/ms_enclave/sandbox/manager/volcengine.py index 136cec8..93ec6da 100644 --- a/ms_enclave/sandbox/manager/volcengine.py +++ b/ms_enclave/sandbox/manager/volcengine.py @@ -50,8 +50,7 @@ class VolcengineSandboxManager(SandboxManager): def __init__(self, config: Optional[SandboxManagerConfig] = None, **kwargs): if config is None: raise ValueError( - 'VolcengineSandboxManager requires a VolcengineSandboxManagerConfig ' - '(with at least `base_url`).' + 'VolcengineSandboxManager requires a VolcengineSandboxManagerConfig (with at least `base_url`).' ) if not isinstance(config, VolcengineSandboxManagerConfig): # Best-effort upcast: build a VolcengineSandboxManagerConfig from the diff --git a/ms_enclave/sandbox/model/base.py b/ms_enclave/sandbox/model/base.py index e128093..c419a68 100644 --- a/ms_enclave/sandbox/model/base.py +++ b/ms_enclave/sandbox/model/base.py @@ -18,6 +18,7 @@ class SandboxStatus(str, Enum): class SandboxType(str, Enum): """Sandbox type enumeration.""" + DOCKER = 'docker' DOCKER_NOTEBOOK = 'docker_notebook' VOLCENGINE = 'volcengine' @@ -66,6 +67,7 @@ def is_compatible(cls, sandbox_type: 'SandboxType', required_type: 'SandboxType' class SandboxManagerType(str, Enum): """Sandbox manager type enumeration.""" + LOCAL = 'local' HTTP = 'http' VOLCENGINE = 'volcengine' @@ -73,6 +75,7 @@ class SandboxManagerType(str, Enum): class ToolType(str, Enum): """Tool type enumeration.""" + SANDBOX = 'sandbox' FUNCTION = 'function' EXTERNAL = 'external' diff --git a/ms_enclave/sandbox/model/config.py b/ms_enclave/sandbox/model/config.py index 07f0b31..3cd4e84 100644 --- a/ms_enclave/sandbox/model/config.py +++ b/ms_enclave/sandbox/model/config.py @@ -13,8 +13,9 @@ class SandboxManagerConfig(BaseModel): api_key: Optional[str] = Field(None, description='API key for authentication') cleanup_interval: Optional[int] = Field(default=None, description='Cleanup interval in seconds') pool_size: int = Field(default=0, description='Sandbox pool size (0 = disabled)') - sandbox_config: Optional[Union['SandboxConfig', - Dict[str, Any]]] = Field(None, description='Default sandbox configuration for pool') + sandbox_config: Optional[Union['SandboxConfig', Dict[str, Any]]] = Field( + None, description='Default sandbox configuration for pool' + ) @field_validator('pool_size') def validate_pool_size(cls, v): @@ -37,8 +38,9 @@ class SandboxConfig(BaseModel): """Base sandbox configuration.""" timeout: int = Field(default=30, description='Default timeout in seconds') - tools_config: Union[List[str], Dict[str, Dict[ - str, Any]]] = Field(default_factory=dict, description='Configuration for tools within the sandbox') + tools_config: Union[List[str], Dict[str, Dict[str, Any]]] = Field( + default_factory=dict, description='Configuration for tools within the sandbox' + ) working_dir: str = Field(default='/sandbox', description='Default working directory') env_vars: Dict[str, str] = Field(default_factory=dict, description='Environment variables') resource_limits: Dict[str, Any] = Field(default_factory=dict, description='Resource limits') @@ -65,14 +67,14 @@ class DockerSandboxConfig(SandboxConfig): command: Optional[Union[str, List[str]]] = Field(None, description='Container command') volumes: Dict[str, Dict[str, str]] = Field( default_factory=dict, - description="Volume mounts. Format: { host_path: {'bind': container_path, 'mode': 'rw|ro'} }" + description="Volume mounts. Format: { host_path: {'bind': container_path, 'mode': 'rw|ro'} }", ) ports: Dict[str, Union[int, str, Tuple[str, int]]] = Field(default_factory=dict, description='Port mappings') network: Optional[str] = Field('bridge', description='Network name') extra_hosts: Dict[str, str] = Field( default_factory=dict, description=( - 'Additional ``hostname -> ip`` entries written to the container\'s /etc/hosts. ' + "Additional ``hostname -> ip`` entries written to the container's /etc/hosts. " 'Use the special value ``host-gateway`` to resolve a hostname to the Docker host ' '(e.g. ``{"host.docker.internal": "host-gateway"}`` so processes inside the ' 'container can reach a service on the host on Linux, where the alias is not ' @@ -82,7 +84,7 @@ class DockerSandboxConfig(SandboxConfig): platform: Optional[str] = Field( default=None, description='Docker platform (e.g. "linux/amd64"). Required when the image arch differs from the host ' - '(e.g. amd64 image on Apple Silicon).' + '(e.g. amd64 image on Apple Silicon).', ) memory_limit: str = Field(default='4g', description='Memory limit') cpu_limit: float = Field(default=2.0, description='CPU limit') @@ -91,11 +93,11 @@ class DockerSandboxConfig(SandboxConfig): remove_on_exit: bool = Field(default=True, description='Remove container on exit') pull_progress: bool = Field( default=False, - description='If True, stream image pull progress to the logger (aggregated every pull_progress_interval seconds).' + description='If True, stream image pull progress to the logger (aggregated every pull_progress_interval seconds).', ) pull_progress_interval: float = Field( default=3.0, - description='Seconds between aggregated pull-progress log lines (only used when pull_progress=True).' + description='Seconds between aggregated pull-progress log lines (only used when pull_progress=True).', ) docker_executor_workers: int = Field( default=8, description='Max worker threads in the dedicated thread pool used for blocking docker SDK calls.' @@ -108,6 +110,7 @@ def validate_memory_limit(cls, v): raise ValueError('Memory limit must be a string') # Basic validation for memory format (e.g., '512m', '1g', '2G') import re + if not re.match(r'^\d+[kmgKMG]?$', v): raise ValueError('Invalid memory limit format') return v @@ -173,9 +176,9 @@ class VolcengineSandboxConfig(SandboxConfig): ) # ---- Sandbox-local options ---- - tool_language_map: Dict[ - str, - str] = Field(default_factory=dict, description='Per-tool language override, e.g. {"shell_executor": "bash"}.') + tool_language_map: Dict[str, str] = Field( + default_factory=dict, description='Per-tool language override, e.g. {"shell_executor": "bash"}.' + ) @field_validator('base_url') @classmethod @@ -202,9 +205,9 @@ class VolcengineSandboxManagerConfig(SandboxManagerConfig): run_code_path: str = Field(default='/run_code', description='Path of the run_code endpoint') extra_headers: Optional[Dict[str, str]] = Field(None, description='Extra HTTP headers to attach to every request') max_concurrency: int = Field(default=16, description='Maximum concurrent requests shared by this manager') - dataset_language_map: Optional[ - Dict[str, str] - ] = Field(None, description='Optional language rename map applied before calling /run_code (e.g. {"r": "R"}).') + dataset_language_map: Optional[Dict[str, str]] = Field( + None, description='Optional language rename map applied before calling /run_code (e.g. {"r": "R"}).' + ) @field_validator('base_url') def validate_base_url(cls, v): diff --git a/ms_enclave/sandbox/server/server.py b/ms_enclave/sandbox/server/server.py index a1c3dc3..c66fa60 100644 --- a/ms_enclave/sandbox/server/server.py +++ b/ms_enclave/sandbox/server/server.py @@ -23,8 +23,7 @@ class SandboxServer: - """FastAPI-based sandbox server. - """ + """FastAPI-based sandbox server.""" def __init__(self, config: Optional[SandboxManagerConfig] = None, **kwargs): """Initialize sandbox server. @@ -39,7 +38,7 @@ def __init__(self, config: Optional[SandboxManagerConfig] = None, **kwargs): title='Sandbox API', description='Agent sandbox execution environment', version='1.0.0', - lifespan=self.lifespan + lifespan=self.lifespan, ) self._setup_middleware() self._setup_auth_middleware() @@ -98,7 +97,7 @@ async def health_check(): version='1.0.0', uptime=time.time() - self.start_time, active_sandboxes=stats['total_sandboxes'], - system_info=stats + system_info=stats, ) # Sandbox management @@ -198,7 +197,7 @@ async def initialize_pool( return { 'message': 'Pool initialized successfully', 'pool_size': len(sandbox_ids), - 'sandbox_ids': sandbox_ids + 'sandbox_ids': sandbox_ids, } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -225,6 +224,7 @@ def run(self, host: str = '0.0.0.0', port: int = 8000, **kwargs): **kwargs: Additional uvicorn arguments """ import uvicorn + uvicorn.run(self.app, host=host, port=port, **kwargs) diff --git a/ms_enclave/sandbox/tools/sandbox_tools/file_operation.py b/ms_enclave/sandbox/tools/sandbox_tools/file_operation.py index fcfbf44..362f46f 100644 --- a/ms_enclave/sandbox/tools/sandbox_tools/file_operation.py +++ b/ms_enclave/sandbox/tools/sandbox_tools/file_operation.py @@ -31,23 +31,13 @@ class FileOperation(SandboxTool): 'operation': { 'type': 'string', 'description': 'Type of file operation to perform', - 'enum': ['create', 'read', 'write', 'delete', 'list', 'exists'] + 'enum': ['create', 'read', 'write', 'delete', 'list', 'exists'], }, - 'file_path': { - 'type': 'string', - 'description': 'Path to the file or directory' - }, - 'content': { - 'type': 'string', - 'description': 'Content to write to file (only for write operation)' - }, - 'encoding': { - 'type': 'string', - 'description': 'File encoding', - 'default': 'utf-8' - } + 'file_path': {'type': 'string', 'description': 'Path to the file or directory'}, + 'content': {'type': 'string', 'description': 'Content to write to file (only for write operation)'}, + 'encoding': {'type': 'string', 'description': 'File encoding', 'default': 'utf-8'}, }, - required=['operation', 'file_path'] + required=['operation', 'file_path'], ) async def execute( @@ -56,7 +46,7 @@ async def execute( operation: str, file_path: str, content: Optional[str] = None, - encoding: str = 'utf-8' + encoding: str = 'utf-8', ) -> ToolResult: """Perform file operations in the Docker container. @@ -88,7 +78,7 @@ async def execute( tool_name=self.name, status=ExecutionStatus.ERROR, output='', - error='Content is required for write operation' + error='Content is required for write operation', ) return await self._write_file(sandbox_context, file_path, content, encoding) elif operation == 'delete': @@ -107,7 +97,7 @@ async def execute( tool_name=self.name, status=ExecutionStatus.ERROR, output='', - error=f'Unknown operation: {operation}' + error=f'Unknown operation: {operation}', ) except Exception as e: @@ -138,7 +128,7 @@ async def _read_file(self, sandbox_context: 'Sandbox', file_path: str, encoding: tool_name=self.name, status=ExecutionStatus.ERROR, output='', - error=result.stderr or f'Failed to read file: {file_path}' + error=result.stderr or f'Failed to read file: {file_path}', ) except Exception as e: return ToolResult( @@ -187,14 +177,14 @@ async def _write_file(self, sandbox_context: 'Sandbox', file_path: str, content: tool_name=self.name, status=ExecutionStatus.ERROR, output='', - error=f'Failed to copy file to target location: {copy_result.stderr or "Unknown error"}' + error=f'Failed to copy file to target location: {copy_result.stderr or "Unknown error"}', ) return ToolResult( tool_name=self.name, status=ExecutionStatus.SUCCESS, output=f'File written successfully: {file_path}', - error=None + error=None, ) except Exception as e: return ToolResult( @@ -222,14 +212,14 @@ async def _delete_file(self, sandbox_context: 'Sandbox', file_path: str) -> Tool tool_name=self.name, status=ExecutionStatus.SUCCESS, output=f'Successfully deleted: {file_path}', - error=None + error=None, ) else: return ToolResult( tool_name=self.name, status=ExecutionStatus.ERROR, output='', - error=result.stderr or f'Failed to delete: {file_path}' + error=result.stderr or f'Failed to delete: {file_path}', ) except Exception as e: return ToolResult( @@ -259,7 +249,7 @@ async def _list_directory(self, sandbox_context: 'Sandbox', dir_path: str) -> To tool_name=self.name, status=ExecutionStatus.ERROR, output='', - error=result.stderr or f'Failed to list directory: {dir_path}' + error=result.stderr or f'Failed to list directory: {dir_path}', ) except Exception as e: return ToolResult( @@ -288,7 +278,7 @@ async def _check_exists(self, sandbox_context: 'Sandbox', file_path: str) -> Too tool_name=self.name, status=ExecutionStatus.SUCCESS, output=f'{"exists" if exists else "does not exist"}', - error=None + error=None, ) except Exception as e: return ToolResult( diff --git a/ms_enclave/sandbox/tools/sandbox_tools/multi_code_executor.py b/ms_enclave/sandbox/tools/sandbox_tools/multi_code_executor.py index 74efbfb..8520780 100644 --- a/ms_enclave/sandbox/tools/sandbox_tools/multi_code_executor.py +++ b/ms_enclave/sandbox/tools/sandbox_tools/multi_code_executor.py @@ -28,33 +28,22 @@ class MultiCodeExecutor(SandboxTool): type='object', properties={ 'language': { - 'type': - 'string', - 'description': - 'Language to execute (python, cpp, csharp, go, java, nodejs, ts, rust, php, bash, pytest, jest, go_test, lua, r, perl, d_ut, ruby, scala, julia, kotlin_script, verilog, lean, swift, racket)' # noqa: E501 - }, - 'code': { 'type': 'string', - 'description': 'Source code to execute' + 'description': 'Language to execute (python, cpp, csharp, go, java, nodejs, ts, rust, php, bash, pytest, jest, go_test, lua, r, perl, d_ut, ruby, scala, julia, kotlin_script, verilog, lean, swift, racket)', # noqa: E501 }, + 'code': {'type': 'string', 'description': 'Source code to execute'}, 'files': { 'type': 'object', - 'additionalProperties': { - 'type': 'string' - }, - 'description': 'Optional additional files to write before execution (filename -> content)' + 'additionalProperties': {'type': 'string'}, + 'description': 'Optional additional files to write before execution (filename -> content)', }, 'compile_timeout': { 'type': 'integer', - 'description': 'Compile timeout in seconds (omit to disable compilation timeout)' + 'description': 'Compile timeout in seconds (omit to disable compilation timeout)', }, - 'run_timeout': { - 'type': 'integer', - 'description': 'Run timeout in seconds', - 'default': 30 - } + 'run_timeout': {'type': 'integer', 'description': 'Run timeout in seconds', 'default': 30}, }, - required=['language', 'code'] + required=['language', 'code'], ) # cache for optional C++ runtime flags detected in the running container @@ -104,7 +93,7 @@ async def execute( code: str, files: Optional[Dict[str, str]] = None, compile_timeout: Optional[int] = None, - run_timeout: Optional[int] = 30 + run_timeout: Optional[int] = 30, ) -> ToolResult: """Execute code by preparing a per-run workdir under /tmp and issuing build/run commands.""" if not language or not code.strip(): @@ -151,7 +140,7 @@ async def execute( tool_name=self.name, status=ExecutionStatus.ERROR, output='', - error=f'Unsupported language: {language}' + error=f'Unsupported language: {language}', ) main_path = os.path.join(workdir, main_filename) await sandbox_context.put_file(main_path, code) @@ -187,20 +176,22 @@ async def execute( tool_name=self.name, status=ExecutionStatus.ERROR, output=build_res.stdout, - error=build_res.stderr or 'Compilation failed' + error=build_res.stderr or 'Compilation failed', ) compile_output = build_res.stdout # Run phase run_res = await self._exec_in_dir(sandbox_context, workdir, run_cmd, timeout=run_timeout or 30) - status = ExecutionStatus.TIMEOUT if run_res.status == ExecutionStatus.TIMEOUT else ( - ExecutionStatus.SUCCESS if run_res.exit_code == 0 else ExecutionStatus.ERROR + status = ( + ExecutionStatus.TIMEOUT + if run_res.status == ExecutionStatus.TIMEOUT + else (ExecutionStatus.SUCCESS if run_res.exit_code == 0 else ExecutionStatus.ERROR) ) return ToolResult( tool_name=self.name, status=status, output='\n'.join([s for s in [compile_output, run_res.stdout] if s]), - error=run_res.stderr if run_res.stderr else None + error=run_res.stderr if run_res.stderr else None, ) except Exception as e: return ToolResult( @@ -255,8 +246,9 @@ async def _python_env_prefix(self, sandbox_context: 'DockerSandbox') -> str: return '' return f'export PATH="{new_path}";' - async def _get_cpp_rt_flags(self, sandbox_context: 'DockerSandbox', workdir: str, - timeout: Optional[int]) -> List[str]: + async def _get_cpp_rt_flags( + self, sandbox_context: 'DockerSandbox', workdir: str, timeout: Optional[int] + ) -> List[str]: """Detect available optional gcc link flags by compiling a tiny program.""" optional_flags = ['-lcrypto', '-lssl', '-lpthread'] # Write a tiny C++ file @@ -296,7 +288,7 @@ async def _prebuild_setup( tool_name=self.name, status=ExecutionStatus.ERROR, output=init_res.stdout, - error=init_res.stderr or 'dotnet project initialization failed' + error=init_res.stderr or 'dotnet project initialization failed', ) return None @@ -305,8 +297,9 @@ def _cpp_commands(self, main_filename: str) -> Tuple[Optional[str], str]: flags = ' ' + ' '.join(MultiCodeExecutor._cpp_rt_flags_cache) if MultiCodeExecutor._cpp_rt_flags_cache else '' return f'g++ -std=c++17 {main_filename} -o app{flags}', './app' - def _scala_commands(self, main_filename: str, code: Optional[str], - scala_classname: Optional[str]) -> Tuple[Optional[str], str]: + def _scala_commands( + self, main_filename: str, code: Optional[str], scala_classname: Optional[str] + ) -> Tuple[Optional[str], str]: """Build and run commands for Scala, ensuring an entrypoint object exists.""" cls = scala_classname or (self._find_scala_classname(code or '') if code else None) if not cls: @@ -314,68 +307,48 @@ def _scala_commands(self, main_filename: str, code: Optional[str], return None, '/bin/sh -lc "echo Object name not found.; exit 1"' return f'scalac {main_filename}', f'scala {cls}' - def _build_commands(self, lang: str, main_filename: str, code: Optional[str], - scala_classname: Optional[str]) -> Tuple[Optional[str], str]: + def _build_commands( + self, lang: str, main_filename: str, code: Optional[str], scala_classname: Optional[str] + ) -> Tuple[Optional[str], str]: """Mapping-based command builder for clarity and extensibility.""" builders = { - 'python': - lambda: (None, f'python {main_filename}'), - 'pytest': - lambda: (None, f'pytest {main_filename}'), - 'cpp': - lambda: self._cpp_commands(main_filename), - 'csharp': - lambda: (None, 'dotnet run --project .'), - 'go': - lambda: (f'go build -o app {main_filename}', './app'), - 'go_test': - lambda: (None, f'go mod init {main_filename} && go test {main_filename}'), - 'java': - lambda: - (f'javac -cp "{self.JAVA_RUNTIME_CP}" {main_filename}', f'java -ea -cp "{self.JAVA_RUNTIME_CP}" Main'), - 'junit': - lambda: (f'javac -cp "{self.JAVA_RUNTIME_CP}" *.java', f'java -ea -cp "{self.JAVA_RUNTIME_CP}" Main'), - 'nodejs': - lambda: (None, f'node {main_filename}'), - 'js': - lambda: (None, f'node {main_filename}'), - 'ts': - lambda: (None, f'tsx {main_filename}'), - 'typescript': - lambda: (None, f'tsx {main_filename}'), - 'rust': - lambda: (f'rustc {main_filename} -o app', './app'), - 'php': - lambda: (None, f'php -f {main_filename}'), - 'bash': - lambda: (None, f'/bin/bash {main_filename}'), - 'jest': - lambda: (None, 'npm run test'), - 'lua': - lambda: (None, f'lua {main_filename}'), - 'r': - lambda: (None, f'Rscript {main_filename}'), - 'perl': - lambda: (None, f'perl {main_filename}'), - 'd_ut': - lambda: (f'dmd {main_filename} -unittest -of=test', './test'), - 'ruby': - lambda: (None, f'ruby {main_filename}'), - 'scala': - lambda: self._scala_commands(main_filename, code, scala_classname), - 'julia': - lambda: (None, f'julia {main_filename}'), - 'kotlin_script': - lambda: (None, f'kotlin {main_filename}'), - 'verilog': - lambda: - (f'iverilog -Wall -Winfloop -Wno-timescale -g2012 -s tb -o test.vvp {main_filename}', 'vvp -n test.vvp'), - 'lean': - lambda: (None, 'lake build || lean --run Main.lean'), - 'swift': - lambda: (f'swiftc {main_filename} -o app', './app'), - 'racket': - lambda: (None, f'racket {main_filename}'), + 'python': lambda: (None, f'python {main_filename}'), + 'pytest': lambda: (None, f'pytest {main_filename}'), + 'cpp': lambda: self._cpp_commands(main_filename), + 'csharp': lambda: (None, 'dotnet run --project .'), + 'go': lambda: (f'go build -o app {main_filename}', './app'), + 'go_test': lambda: (None, f'go mod init {main_filename} && go test {main_filename}'), + 'java': lambda: ( + f'javac -cp "{self.JAVA_RUNTIME_CP}" {main_filename}', + f'java -ea -cp "{self.JAVA_RUNTIME_CP}" Main', + ), + 'junit': lambda: ( + f'javac -cp "{self.JAVA_RUNTIME_CP}" *.java', + f'java -ea -cp "{self.JAVA_RUNTIME_CP}" Main', + ), + 'nodejs': lambda: (None, f'node {main_filename}'), + 'js': lambda: (None, f'node {main_filename}'), + 'ts': lambda: (None, f'tsx {main_filename}'), + 'typescript': lambda: (None, f'tsx {main_filename}'), + 'rust': lambda: (f'rustc {main_filename} -o app', './app'), + 'php': lambda: (None, f'php -f {main_filename}'), + 'bash': lambda: (None, f'/bin/bash {main_filename}'), + 'jest': lambda: (None, 'npm run test'), + 'lua': lambda: (None, f'lua {main_filename}'), + 'r': lambda: (None, f'Rscript {main_filename}'), + 'perl': lambda: (None, f'perl {main_filename}'), + 'd_ut': lambda: (f'dmd {main_filename} -unittest -of=test', './test'), + 'ruby': lambda: (None, f'ruby {main_filename}'), + 'scala': lambda: self._scala_commands(main_filename, code, scala_classname), + 'julia': lambda: (None, f'julia {main_filename}'), + 'kotlin_script': lambda: (None, f'kotlin {main_filename}'), + 'verilog': lambda: ( + f'iverilog -Wall -Winfloop -Wno-timescale -g2012 -s tb -o test.vvp {main_filename}', + 'vvp -n test.vvp', + ), + 'lean': lambda: (None, 'lake build || lean --run Main.lean'), + 'swift': lambda: (f'swiftc {main_filename} -o app', './app'), + 'racket': lambda: (None, f'racket {main_filename}'), } builder = builders.get(lang) if builder is None: @@ -383,11 +356,7 @@ def _build_commands(self, lang: str, main_filename: str, code: Optional[str], return builder() def _commands_for_language( - self, - language: str, - main_filename: str, - code: Optional[str] = None, - scala_classname: Optional[str] = None + self, language: str, main_filename: str, code: Optional[str] = None, scala_classname: Optional[str] = None ) -> Tuple[Optional[str], str]: """Return (build_cmd, run_cmd) for the given language, executed with cwd=workdir.""" lang = language.lower().strip() diff --git a/ms_enclave/sandbox/tools/sandbox_tools/notebook_executor.py b/ms_enclave/sandbox/tools/sandbox_tools/notebook_executor.py index 257866c..d5f6ffe 100644 --- a/ms_enclave/sandbox/tools/sandbox_tools/notebook_executor.py +++ b/ms_enclave/sandbox/tools/sandbox_tools/notebook_executor.py @@ -19,24 +19,16 @@ @register_tool('notebook_executor') class NotebookExecutor(SandboxTool): - _name = 'notebook_executor' _sandbox_types = [SandboxType.DOCKER_NOTEBOOK] _description = 'Execute Python code in a Jupyter kernel environment' _parameters = ToolParams( type='object', properties={ - 'code': { - 'type': 'string', - 'description': 'Python code to execute in the notebook kernel' - }, - 'timeout': { - 'type': 'integer', - 'description': 'Execution timeout in seconds', - 'default': 30 - } + 'code': {'type': 'string', 'description': 'Python code to execute in the notebook kernel'}, + 'timeout': {'type': 'integer', 'description': 'Execution timeout in seconds', 'default': 30}, }, - required=['code'] + required=['code'], ) async def execute(self, sandbox_context: 'Sandbox', code: str, timeout: Optional[int] = 30) -> ToolResult: @@ -57,10 +49,7 @@ async def execute(self, sandbox_context: 'Sandbox', code: str, timeout: Optional status = ExecutionStatus.ERROR return ToolResult( - tool_name=self.name, - status=status, - output=result.stdout, - error=result.stderr if result.stderr else None + tool_name=self.name, status=status, output=result.stdout, error=result.stderr if result.stderr else None ) except Exception as e: @@ -139,12 +128,13 @@ async def _execute_in_kernel(self, sandbox_context: 'Sandbox', code: str, timeou output_text += f'\n{error_msg}' return CommandResult( - status=ExecutionStatus.TIMEOUT if timed_out else - (ExecutionStatus.SUCCESS if not error_occurred else ExecutionStatus.ERROR), + status=ExecutionStatus.TIMEOUT + if timed_out + else (ExecutionStatus.SUCCESS if not error_occurred else ExecutionStatus.ERROR), command=code, exit_code=1 if error_occurred else 0, stdout=output_text if not error_occurred else '', - stderr=output_text if error_occurred else '' + stderr=output_text if error_occurred else '', ) async def _interrupt_kernel(self, sandbox_context: 'Sandbox') -> None: @@ -153,11 +143,13 @@ async def _interrupt_kernel(self, sandbox_context: 'Sandbox') -> None: def _post_interrupt(): import requests + url = f'{sandbox_context.base_url}/api/kernels/{sandbox_context.kernel_id}/interrupt' return requests.post(url, timeout=5) try: import asyncio + response = await asyncio.to_thread(_post_interrupt) if response.status_code not in (200, 204): logger.warning(f'Kernel interrupt returned HTTP {response.status_code}: {response.text}') diff --git a/ms_enclave/sandbox/tools/sandbox_tools/python_executor.py b/ms_enclave/sandbox/tools/sandbox_tools/python_executor.py index 77f82f4..5eb1a4e 100644 --- a/ms_enclave/sandbox/tools/sandbox_tools/python_executor.py +++ b/ms_enclave/sandbox/tools/sandbox_tools/python_executor.py @@ -14,24 +14,16 @@ @register_tool('python_executor') class PythonExecutor(SandboxTool): - _name = 'python_executor' _sandbox_types = [SandboxType.DOCKER, SandboxType.VOLCENGINE] _description = 'Execute Python code in an isolated environment using IPython' _parameters = ToolParams( type='object', properties={ - 'code': { - 'type': 'string', - 'description': 'Python code to execute' - }, - 'timeout': { - 'type': 'integer', - 'description': 'Execution timeout in seconds', - 'default': 30 - } + 'code': {'type': 'string', 'description': 'Python code to execute'}, + 'timeout': {'type': 'integer', 'description': 'Execution timeout in seconds', 'default': 30}, }, - required=['code'] + required=['code'], ) async def execute(self, sandbox_context: 'DockerSandbox', code: str, timeout: Optional[int] = 30) -> ToolResult: @@ -56,7 +48,6 @@ async def execute(self, sandbox_context: 'DockerSandbox', code: str, timeout: Op script_path = f'/tmp/{script_basename}' try: - # Write script to container to avoid long code errors await sandbox_context.put_file(script_path, code) @@ -72,10 +63,7 @@ async def execute(self, sandbox_context: 'DockerSandbox', code: str, timeout: Op status = ExecutionStatus.ERROR return ToolResult( - tool_name=self.name, - status=status, - output=result.stdout, - error=result.stderr if result.stderr else None + tool_name=self.name, status=status, output=result.stdout, error=result.stderr if result.stderr else None ) except Exception as e: return ToolResult( diff --git a/ms_enclave/sandbox/tools/sandbox_tools/shell_executor.py b/ms_enclave/sandbox/tools/sandbox_tools/shell_executor.py index 46dac38..2be02bb 100644 --- a/ms_enclave/sandbox/tools/sandbox_tools/shell_executor.py +++ b/ms_enclave/sandbox/tools/sandbox_tools/shell_executor.py @@ -14,7 +14,6 @@ @register_tool('shell_executor') class ShellExecutor(SandboxTool): - _name = 'shell_executor' _sandbox_types = [SandboxType.DOCKER, SandboxType.VOLCENGINE] _description = 'Execute shell commands in an isolated environment' @@ -22,24 +21,18 @@ class ShellExecutor(SandboxTool): type='object', properties={ 'command': { - 'anyOf': [{ - 'type': 'string', - 'description': 'Shell command to execute' - }, { - 'type': 'array', - 'items': { - 'type': 'string' + 'anyOf': [ + {'type': 'string', 'description': 'Shell command to execute'}, + { + 'type': 'array', + 'items': {'type': 'string'}, + 'description': 'List of shell command arguments to execute', }, - 'description': 'List of shell command arguments to execute' - }] + ] }, - 'timeout': { - 'type': 'integer', - 'description': 'Execution timeout in seconds', - 'default': 30 - } + 'timeout': {'type': 'integer', 'description': 'Execution timeout in seconds', 'default': 30}, }, - required=['command'] + required=['command'], ) async def execute( @@ -71,21 +64,21 @@ async def execute( tool_name=self.name, status=ExecutionStatus.TIMEOUT, output=result.stdout, - error=result.stderr if result.stderr else f'Command timed out after {timeout} seconds' + error=result.stderr if result.stderr else f'Command timed out after {timeout} seconds', ) if result.exit_code == 0: return ToolResult( tool_name=self.name, status=ExecutionStatus.SUCCESS, output=result.stdout, - error=result.stderr if result.stderr else None + error=result.stderr if result.stderr else None, ) else: return ToolResult( tool_name=self.name, status=ExecutionStatus.ERROR, output=result.stdout, - error=result.stderr if result.stderr else f'Command failed with exit code {result.exit_code}' + error=result.stderr if result.stderr else f'Command failed with exit code {result.exit_code}', ) except Exception as e: diff --git a/ms_enclave/sandbox/tools/tool_info.py b/ms_enclave/sandbox/tools/tool_info.py index 7e7117c..4a497e3 100644 --- a/ms_enclave/sandbox/tools/tool_info.py +++ b/ms_enclave/sandbox/tools/tool_info.py @@ -54,15 +54,14 @@ class ToolInfo(BaseModel): parameters: ToolParams = Field(default_factory=ToolParams, description='JSON Schema of tool parameters object.') options: Optional[Dict[str, object]] = Field( default=None, - description= - 'Optional property bag that can be used by the model provider to customize the implementation of the tool' + description='Optional property bag that can be used by the model provider to customize the implementation of the tool', ) def parse_tool_info(func: Callable[..., Any]) -> ToolInfo: # tool may already have registry attributes w/ tool info - if (getattr(func, 'name', None) and getattr(func, 'description', None) and getattr(func, 'parameters', None)): + if getattr(func, 'name', None) and getattr(func, 'description', None) and getattr(func, 'parameters', None): return ToolInfo( name=func.name, description=func.description, diff --git a/ms_enclave/utils/json_schema.py b/ms_enclave/utils/json_schema.py index 66670b4..2bd7d25 100644 --- a/ms_enclave/utils/json_schema.py +++ b/ms_enclave/utils/json_schema.py @@ -96,7 +96,7 @@ def json_schema(t: Type[Any]) -> JSONSchema: return JSONSchema(type='array', items=JSONSchema()) elif t is dict: return JSONSchema(type='object', additionalProperties=JSONSchema()) - elif (is_dataclass(t) or is_typeddict(t) or (isinstance(t, type) and issubclass(t, BaseModel))): + elif is_dataclass(t) or is_typeddict(t) or (isinstance(t, type) and issubclass(t, BaseModel)): return cls_json_schema(t) elif isinstance(t, EnumMeta): return JSONSchema(enum=[item.value for item in t]) @@ -104,7 +104,7 @@ def json_schema(t: Type[Any]) -> JSONSchema: return JSONSchema(type='null') else: return JSONSchema() - elif (origin is list or origin is List or origin is tuple or origin is Tuple or origin is set or origin is Set): + elif origin is list or origin is List or origin is tuple or origin is Tuple or origin is set or origin is Set: return JSONSchema(type='array', items=json_schema(args[0]) if args else JSONSchema()) elif origin is dict or origin is Dict: return JSONSchema( diff --git a/pyproject.toml b/pyproject.toml index 000c1fe..872e94c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,25 @@ Homepage = "https://github.com/modelscope/ms-enclave" [project.scripts] ms-enclave = "ms_enclave.cli.cli:run_cmd" +[tool.pytest.ini_options] +asyncio_mode = "auto" + +[tool.ruff] +extend-exclude = ["build", "dist", "docs", "examples"] +force-exclude = true +line-length = 120 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "W", "I"] +ignore = ["E501", "E741", "F401", "F403", "F405", "F821"] + +[tool.ruff.format] +exclude = ["tests/**/*.py"] +indent-style = "space" +line-ending = "lf" +quote-style = "single" + [tool.setuptools] include-package-data = true @@ -48,6 +67,7 @@ dev = [ "pre-commit>=4.2.0", "jupyter-book>=1.0.4.post1", "pytest-cov>=6.2.1", + "ruff>=0.16.4", ] docker = [ diff --git a/setup.cfg b/setup.cfg index c7a3c23..7e1da7f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,34 +1,8 @@ -[isort] -line_length =120 -multi_line_output = 3 -include_trailing_comma = true -force_grid_wrap = 0 -use_parentheses = true -ensure_newline_before_comments = true - -[yapf] -based_on_style = pep8 -column_limit = 120 -dedent_closing_brackets = true -coalesce_brackets = true -split_before_logical_operator = true -split_before_arithmetic_operator = true -split_before_bitwise_operator = true -split_complex_comprehension = true -indent_width = 4 -use_tabs = false - [codespell] skip = *.ipynb quiet-level = 3 ignore-words-list = patten,nd,ty,mot,hist,formating,winn,gool,datas,wan,confids -[flake8] -max-line-length = 120 -select = B,C,E,F,P,T4,W,B9 -ignore = F401,F403,F405,F821,W503,E251,W504,E501,E741 -exclude = docs,*.pyi,.git - [darglint] ignore=DAR101 diff --git a/tests/test_manage_pool.py b/tests/test_manage_pool.py index 0b919c5..ac480fc 100644 --- a/tests/test_manage_pool.py +++ b/tests/test_manage_pool.py @@ -178,6 +178,48 @@ async def test_sequential_execution_reuses_sandbox(self): self.assertEqual(len(self.manager._sandbox_pool), 1) self.assertEqual(self.manager._sandbox_pool[0], sandbox_ids[0]) + async def test_manager_waits_for_cancelled_execution_recovery(self): + config = DockerSandboxConfig( + image='python:3.11-slim', + tools_config={'shell_executor': {}} + ) + sandbox_id = await self.manager.create_sandbox(SandboxType.DOCKER, config) + marker = '/tmp/ms-enclave-manager-cancel-marker' + await self.manager.execute_tool( + sandbox_id, 'shell_executor', {'command': ['bash', '-c', f'rm -f {marker}'], 'timeout': 5} + ) + + first = asyncio.create_task( + self.manager.execute_tool( + sandbox_id, + 'shell_executor', + {'command': ['bash', '-c', f'(sleep 2; touch {marker}) &'], 'timeout': 30}, + ) + ) + await asyncio.sleep(0.2) + second = asyncio.create_task( + self.manager.execute_tool( + sandbox_id, 'shell_executor', {'command': ['echo', 'after-recovery'], 'timeout': 5} + ) + ) + await asyncio.sleep(0.1) + self.assertFalse(second.done()) + + first.cancel() + with self.assertRaises(asyncio.CancelledError): + await first + + result = await asyncio.wait_for(second, timeout=20) + self.assertTrue(result.success) + self.assertIn('after-recovery', result.output) + await asyncio.sleep(2.5) + check = await self.manager.execute_tool( + sandbox_id, + 'shell_executor', + {'command': ['bash', '-c', f'test -e {marker} && echo PRESENT || echo ABSENT'], 'timeout': 5}, + ) + self.assertEqual(check.output.strip(), 'ABSENT') + class TestConcurrentPoolExecution(unittest.IsolatedAsyncioTestCase): """Test concurrent execution with pool.""" diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index 3e4a824..8c0ba18 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -117,6 +117,20 @@ async def test_docker_entrypoint_inherits_image_by_default(self): finally: sandbox._executor.shutdown(wait=False, cancel_futures=True) + async def test_interrupted_exec_reset_failure_marks_sandbox_error(self): + sandbox = DockerSandbox(DockerSandboxConfig(image='example:latest')) + sandbox.client = MagicMock() + sandbox.container = MagicMock() + sandbox._run_blocking = AsyncMock(side_effect=RuntimeError('kill failed')) + try: + with self.assertRaisesRegex(RuntimeError, 'kill failed'): + await sandbox._reset_after_interrupted_exec() + self.assertEqual(sandbox.status, SandboxStatus.ERROR) + with self.assertRaisesRegex(RuntimeError, 'error state'): + await sandbox.execute_command(['echo', 'unreachable']) + finally: + sandbox._executor.shutdown(wait=False, cancel_futures=True) + if __name__ == '__main__': diff --git a/tests/test_tool.py b/tests/test_tool.py index 1fcb3ec..8016921 100644 --- a/tests/test_tool.py +++ b/tests/test_tool.py @@ -155,6 +155,30 @@ async def test_shell_executor_timeout_terminates_grandchild_process(self): ) self.assertEqual(check.output.strip(), 'ABSENT') + async def test_shell_executor_cancellation_restarts_sandbox(self): + self.docker_sandbox.add_tool( + ToolFactory.create_tool('shell_executor') + ) + marker = '/tmp/ms-enclave-shell-cancel-marker' + await self.docker_sandbox.execute_tool( + 'shell_executor', {'command': ['bash', '-c', f'rm -f {marker}'], 'timeout': 5} + ) + task = asyncio.create_task( + self.docker_sandbox.execute_tool( + 'shell_executor', {'command': ['bash', '-c', f'(sleep 2; touch {marker}) &'], 'timeout': 30} + ) + ) + await asyncio.sleep(0.2) + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + + await asyncio.sleep(2.5) + check = await self.docker_sandbox.execute_tool( + 'shell_executor', {'command': ['bash', '-c', f'test -e {marker} && echo PRESENT || echo ABSENT']} + ) + self.assertEqual(check.output.strip(), 'ABSENT') + async def test_python_executor_timeout_terminates_child_process(self): self.docker_sandbox.add_tool( ToolFactory.create_tool('shell_executor') @@ -291,6 +315,26 @@ async def test_notebook_executor_timeout_interrupts_kernel(self): self.assertEqual(check.status, ExecutionStatus.SUCCESS) self.assertIn('ABSENT', check.output) + async def test_shell_cancellation_recreates_notebook_session(self): + self.docker_sandbox.add_tool(ToolFactory.create_tool('shell_executor')) + self.docker_sandbox.add_tool(ToolFactory.create_tool('notebook_executor')) + + task = asyncio.create_task( + self.docker_sandbox.execute_tool( + 'shell_executor', {'command': ['bash', '-c', '(sleep 2) &'], 'timeout': 30} + ) + ) + await asyncio.sleep(0.2) + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + + result = await self.docker_sandbox.execute_tool( + 'notebook_executor', {'code': "print('notebook recovered')"} + ) + self.assertEqual(result.status, ExecutionStatus.SUCCESS) + self.assertIn('notebook recovered', result.output) + if __name__ == '__main__': import asyncio unittest.main()