diff --git a/admin/server/services.py b/admin/server/services.py index cc68f98dc..dc0a41c6c 100644 --- a/admin/server/services.py +++ b/admin/server/services.py @@ -457,11 +457,21 @@ class SandboxMgr: # Provider registry with metadata PROVIDER_REGISTRY = { + "local": { + "name": "Local", + "description": "Execute code directly on the current host process.", + "tags": ["local", "host", "minimal"], + }, "self_managed": { "name": "Self-Managed", "description": "On-premise deployment using Daytona/Docker", "tags": ["self-hosted", "low-latency", "secure"], }, + "ssh": { + "name": "SSH", + "description": "Execute code on a remote machine over SSH.", + "tags": ["remote", "ssh", "custom-runtime"], + }, "aliyun_codeinterpreter": { "name": "Aliyun Code Interpreter", "description": "Aliyun Function Compute Code Interpreter - Code execution in serverless microVMs", @@ -489,13 +499,17 @@ class SandboxMgr: def get_provider_config_schema(provider_id: str): """Get configuration schema for a specific provider.""" from agent.sandbox.providers import ( + LocalProvider, SelfManagedProvider, + SSHProvider, AliyunCodeInterpreterProvider, E2BProvider, ) schemas = { + "local": LocalProvider.get_config_schema(), "self_managed": SelfManagedProvider.get_config_schema(), + "ssh": SSHProvider.get_config_schema(), "aliyun_codeinterpreter": AliyunCodeInterpreterProvider.get_config_schema(), "e2b": E2BProvider.get_config_schema(), } @@ -512,7 +526,6 @@ class SandboxMgr: # Get active provider type provider_type_settings = SystemSettingsService.get_by_name("sandbox.provider_type") if not provider_type_settings: - # Return default config if not set provider_type = "self_managed" else: provider_type = provider_type_settings[0].value @@ -527,6 +540,15 @@ class SandboxMgr: except json.JSONDecodeError: provider_config = {} + if not provider_config: + schema = SandboxMgr.get_provider_config_schema(provider_type) + provider_config = {} + for field_name, field_schema in schema.items(): + if field_schema.get("readonly"): + continue + if field_schema.get("default") is not None: + provider_config[field_name] = field_schema["default"] + return { "provider_type": provider_type, "config": provider_config, @@ -550,7 +572,9 @@ class SandboxMgr: Dictionary with updated provider_type and config """ from agent.sandbox.providers import ( + LocalProvider, SelfManagedProvider, + SSHProvider, AliyunCodeInterpreterProvider, E2BProvider, ) @@ -577,7 +601,7 @@ class SandboxMgr: elif field_type == "string": if not isinstance(config[field_name], str): raise AdminException(f"Field '{field_name}' must be a string") - elif field_type == "bool": + elif field_type == "boolean": if not isinstance(config[field_name], bool): raise AdminException(f"Field '{field_name}' must be a boolean") @@ -592,7 +616,9 @@ class SandboxMgr: # Provider-specific custom validation provider_classes = { + "local": LocalProvider, "self_managed": SelfManagedProvider, + "ssh": SSHProvider, "aliyun_codeinterpreter": AliyunCodeInterpreterProvider, "e2b": E2BProvider, } @@ -608,6 +634,8 @@ class SandboxMgr: # Always update the provider config config_json = json.dumps(config) SettingsMgr.update_by_name(f"sandbox.{provider_type}", config_json) + from agent.sandbox.client import reload_provider + reload_provider() return {"provider_type": provider_type, "config": config} except AdminException: @@ -634,14 +662,18 @@ class SandboxMgr: """ try: from agent.sandbox.providers import ( + LocalProvider, SelfManagedProvider, + SSHProvider, AliyunCodeInterpreterProvider, E2BProvider, ) # Instantiate provider based on type provider_classes = { + "local": LocalProvider, "self_managed": SelfManagedProvider, + "ssh": SSHProvider, "aliyun_codeinterpreter": AliyunCodeInterpreterProvider, "e2b": E2BProvider, } @@ -657,59 +689,40 @@ class SandboxMgr: # Create a temporary sandbox instance for testing instance = provider.create_instance(template="python") + if not instance: + raise AdminException("Failed to create sandbox instance.") - if not instance or instance.status != "READY": - raise AdminException(f"Failed to create sandbox instance. Status: {instance.status if instance else 'None'}") - - # Simple test code that exercises basic Python functionality - test_code = """ -# Test basic Python functionality -import sys + try: + # Simple test code that exercises provider wrapping via main(). + test_code = """ import json import math +import sys -print("Python version:", sys.version) -print("Platform:", sys.platform) -# Test basic calculations -result = 2 + 2 -print(f"2 + 2 = {result}") - -# Test JSON operations -data = {"test": "data", "value": 123} -print(f"JSON dump: {json.dumps(data)}") - -# Test math operations -print(f"Math.sqrt(16) = {math.sqrt(16)}") - -# Test error handling -try: - x = 1 / 1 - print("Division test: OK") -except Exception as e: - print(f"Error: {e}") - -# Return success indicator -print("TEST_PASSED") +def main() -> dict: + print("Python version:", sys.version) + print("Platform:", sys.platform) + print(f"2 + 2 = {2 + 2}") + print(f"JSON dump: {json.dumps({'test': 'data', 'value': 123})}") + print(f"Math.sqrt(16) = {math.sqrt(16)}") + print("TEST_PASSED") + return {"ok": True, "provider_test": "TEST_PASSED"} """ - # Execute test code with timeout - execution_result = provider.execute_code( - instance_id=instance.instance_id, - code=test_code, - language="python", - timeout=10 # 10 seconds timeout - ) - - # Clean up the test instance (if provider supports it) - try: - if hasattr(provider, 'terminate_instance'): - provider.terminate_instance(instance.instance_id) + # Execute test code with timeout + execution_result = provider.execute_code( + instance_id=instance.instance_id, + code=test_code, + language="python", + timeout=10, + ) + finally: + try: + provider.destroy_instance(instance.instance_id) logging.info(f"Cleaned up test instance {instance.instance_id}") - else: - logging.warning(f"Provider {provider_type} does not support terminate_instance, test instance may leak") - except Exception as cleanup_error: - logging.warning(f"Failed to cleanup test instance {instance.instance_id}: {cleanup_error}") + except Exception as cleanup_error: + logging.warning(f"Failed to cleanup test instance {instance.instance_id}: {cleanup_error}") # Build detailed result message success = execution_result.exit_code == 0 and "TEST_PASSED" in execution_result.stdout diff --git a/agent/sandbox/client.py b/agent/sandbox/client.py index 9ca51cc8e..daafb0d07 100644 --- a/agent/sandbox/client.py +++ b/agent/sandbox/client.py @@ -23,7 +23,6 @@ with the configured sandbox provider. import json import logging -import os from typing import Dict, Any, Optional from api.db.services.system_settings_service import SystemSettingsService @@ -49,7 +48,6 @@ def get_provider_manager() -> ProviderManager: if _provider_manager is not None: return _provider_manager - # Initialize provider manager with system settings _provider_manager = ProviderManager() _load_provider_from_settings() @@ -61,7 +59,7 @@ def _load_provider_from_settings() -> None: Load sandbox provider from system settings and configure the provider manager. This function resolves the active provider type, then loads configuration - from system settings with environment overrides for that provider. + from system settings. """ global _provider_manager @@ -69,7 +67,7 @@ def _load_provider_from_settings() -> None: return try: - provider_type, provider_type_from_env = _resolve_provider_type() + provider_type = _resolve_provider_type() config = _load_provider_config(provider_type) # Import and instantiate the provider @@ -78,6 +76,7 @@ def _load_provider_from_settings() -> None: AliyunCodeInterpreterProvider, E2BProvider, LocalProvider, + SSHProvider, ) provider_classes = { @@ -85,11 +84,10 @@ def _load_provider_from_settings() -> None: "aliyun_codeinterpreter": AliyunCodeInterpreterProvider, "e2b": E2BProvider, "local": LocalProvider, + "ssh": SSHProvider, } if provider_type not in provider_classes: - if provider_type_from_env: - raise SandboxProviderConfigError(f"Unknown sandbox provider type: {provider_type}") logger.error(f"Unknown provider type: {provider_type}") return @@ -99,7 +97,7 @@ def _load_provider_from_settings() -> None: # Initialize the provider if not provider.initialize(config): message = f"Failed to initialize sandbox provider: {provider_type}. Config keys: {list(config.keys())}" - if provider_type == "local" or provider_type_from_env: + if provider_type in {"local", "ssh"}: raise SandboxProviderConfigError(message) logger.error(message) return @@ -114,8 +112,6 @@ def _load_provider_from_settings() -> None: logger.error(f"Failed to load sandbox provider from settings: {e}") import traceback traceback.print_exc() - - def _load_provider_config_from_settings(provider_type: str) -> Dict[str, Any]: provider_config_settings = SystemSettingsService.get_by_name(f"sandbox.{provider_type}") if not provider_config_settings: @@ -129,64 +125,15 @@ def _load_provider_config_from_settings(provider_type: str) -> Dict[str, Any]: return {} -def _resolve_provider_type() -> tuple[str, bool]: - provider_type = os.environ.get("SANDBOX_PROVIDER_TYPE", "").strip() - if provider_type: - return provider_type, True - +def _resolve_provider_type() -> str: provider_type_settings = SystemSettingsService.get_by_name("sandbox.provider_type") if not provider_type_settings: - raise RuntimeError( - "Sandbox provider type not configured. Please set 'sandbox.provider_type' in system settings." - ) - return provider_type_settings[0].value, False + return "self_managed" + return provider_type_settings[0].value def _load_provider_config(provider_type: str) -> Dict[str, Any]: - config = _load_provider_config_from_settings(provider_type) - env_config = _load_provider_config_from_env(provider_type) - if env_config: - config.update(env_config) - return config - - -def _load_provider_config_from_env(provider_type: str) -> Dict[str, Any]: - if provider_type == "local": - return _load_local_provider_config_from_env() - if provider_type == "self_managed": - return _load_self_managed_provider_config_from_env() - return {} - - -def _load_local_provider_config_from_env() -> Dict[str, Any]: - env_to_config = { - "SANDBOX_LOCAL_PYTHON_BIN": "python_bin", - "SANDBOX_LOCAL_NODE_BIN": "node_bin", - "SANDBOX_LOCAL_WORK_DIR": "work_dir", - "SANDBOX_LOCAL_TIMEOUT": "timeout", - "SANDBOX_LOCAL_MAX_MEMORY_MB": "max_memory_mb", - "SANDBOX_LOCAL_MAX_OUTPUT_BYTES": "max_output_bytes", - "SANDBOX_LOCAL_MAX_ARTIFACTS": "max_artifacts", - "SANDBOX_LOCAL_MAX_ARTIFACT_BYTES": "max_artifact_bytes", - } - config = {} - for env_name, config_name in env_to_config.items(): - if env_name in os.environ: - config[config_name] = os.environ[env_name] - return config - - -def _load_self_managed_provider_config_from_env() -> Dict[str, Any]: - host = os.environ.get("SANDBOX_HOST", "").strip() - port = os.environ.get("SANDBOX_EXECUTOR_MANAGER_PORT", "").strip() - pool_size = os.environ.get("SANDBOX_EXECUTOR_MANAGER_POOL_SIZE", "").strip() - - config = {} - if host: - config["endpoint"] = f"http://{host}:{port or '9385'}" - if pool_size: - config["pool_size"] = pool_size - return config + return _load_provider_config_from_settings(provider_type) def reload_provider() -> None: @@ -231,6 +178,14 @@ def execute_code( ) provider = provider_manager.get_provider() + provider_name = provider_manager.get_provider_name() or getattr(provider, "__class__", type(provider)).__name__ + + logger.info( + "CodeExec using sandbox provider '%s' (language=%s, timeout=%ss)", + provider_name, + language, + timeout, + ) # Create a sandbox instance instance = provider.create_instance(template=language) diff --git a/agent/sandbox/providers/__init__.py b/agent/sandbox/providers/__init__.py index e7cfc2ddc..b67a982f3 100644 --- a/agent/sandbox/providers/__init__.py +++ b/agent/sandbox/providers/__init__.py @@ -25,6 +25,7 @@ This package contains: Official Documentation: https://help.aliyun.com/zh/functioncompute/fc/sandbox-sandbox-code-interepreter - e2b.py: E2B provider implementation - local.py: Local process provider implementation +- ssh.py: Remote SSH provider implementation """ from .base import SandboxProvider, SandboxInstance, ExecutionResult, SandboxProviderConfigError @@ -33,6 +34,7 @@ from .self_managed import SelfManagedProvider from .aliyun_codeinterpreter import AliyunCodeInterpreterProvider from .e2b import E2BProvider from .local import LocalProvider +from .ssh import SSHProvider __all__ = [ "SandboxProvider", @@ -44,4 +46,5 @@ __all__ = [ "AliyunCodeInterpreterProvider", "E2BProvider", "LocalProvider", + "SSHProvider", ] diff --git a/agent/sandbox/providers/local.py b/agent/sandbox/providers/local.py index 1a82516dc..ed37cc57d 100644 --- a/agent/sandbox/providers/local.py +++ b/agent/sandbox/providers/local.py @@ -49,12 +49,6 @@ LOCAL_PYTHON_THREAD_ENV_VARS = ( "BLIS_NUM_THREADS", "VECLIB_MAXIMUM_THREADS", ) - - -def _env_enabled(name: str) -> bool: - return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"} - - class LocalProvider(SandboxProvider): """ Execute code as a local child process. @@ -76,17 +70,14 @@ class LocalProvider(SandboxProvider): self._instances: dict[str, Path] = {} def initialize(self, config: Dict[str, Any]) -> bool: - if not _env_enabled("SANDBOX_LOCAL_ENABLED"): - raise SandboxProviderConfigError("Local code execution is disabled. Set SANDBOX_LOCAL_ENABLED=true to enable it.") - - self.python_bin = str(self._resolve_config_value(config, "python_bin", "SANDBOX_LOCAL_PYTHON_BIN", "python3")) - self.node_bin = str(self._resolve_config_value(config, "node_bin", "SANDBOX_LOCAL_NODE_BIN", "node")) - self.work_dir = Path(self._resolve_config_value(config, "work_dir", "SANDBOX_LOCAL_WORK_DIR", "/tmp/ragflow-codeexec")).resolve() - self.timeout = int(self._resolve_config_value(config, "timeout", "SANDBOX_LOCAL_TIMEOUT", 30)) - self.max_memory_mb = int(self._resolve_config_value(config, "max_memory_mb", "SANDBOX_LOCAL_MAX_MEMORY_MB", 512)) - self.max_output_bytes = int(self._resolve_config_value(config, "max_output_bytes", "SANDBOX_LOCAL_MAX_OUTPUT_BYTES", 1024 * 1024)) - self.max_artifacts = int(self._resolve_config_value(config, "max_artifacts", "SANDBOX_LOCAL_MAX_ARTIFACTS", 20)) - self.max_artifact_bytes = int(self._resolve_config_value(config, "max_artifact_bytes", "SANDBOX_LOCAL_MAX_ARTIFACT_BYTES", 10 * 1024 * 1024)) + self.python_bin = str(config.get("python_bin", "python3")) + self.node_bin = str(config.get("node_bin", "node")) + self.work_dir = Path(str(config.get("work_dir", "/tmp/ragflow-codeexec"))).resolve() + self.timeout = int(config.get("timeout", 30)) + self.max_memory_mb = int(config.get("max_memory_mb", 512)) + self.max_output_bytes = int(config.get("max_output_bytes", 1024 * 1024)) + self.max_artifacts = int(config.get("max_artifacts", 20)) + self.max_artifact_bytes = int(config.get("max_artifact_bytes", 10 * 1024 * 1024)) self._validate_limits() self.work_dir.mkdir(parents=True, exist_ok=True, mode=0o700) @@ -194,14 +185,72 @@ class LocalProvider(SandboxProvider): @staticmethod def get_config_schema() -> Dict[str, Dict]: return { - "python_bin": {"type": "string", "required": False, "default": "python3"}, - "node_bin": {"type": "string", "required": False, "default": "node"}, - "work_dir": {"type": "string", "required": False, "default": "/tmp/ragflow-codeexec"}, - "timeout": {"type": "integer", "required": False, "default": 30}, - "max_memory_mb": {"type": "integer", "required": False, "default": 512}, - "max_output_bytes": {"type": "integer", "required": False, "default": 1048576}, - "max_artifacts": {"type": "integer", "required": False, "default": 20}, - "max_artifact_bytes": {"type": "integer", "required": False, "default": 10485760}, + "python_bin": { + "type": "string", + "required": False, + "default": "python3", + "label": "Python Binary", + "description": "Python executable used for local code execution.", + }, + "node_bin": { + "type": "string", + "required": False, + "default": "node", + "label": "Node.js Binary", + "description": "Node.js executable used for local JavaScript execution.", + }, + "work_dir": { + "type": "string", + "required": False, + "default": "/tmp/ragflow-codeexec", + "label": "Working Directory", + "description": "Directory used to store temporary scripts and artifacts on the current host.", + }, + "timeout": { + "type": "integer", + "required": False, + "default": 30, + "label": "Timeout (seconds)", + "description": "Maximum execution time for each local run. Unit: seconds.", + "min": 1, + "max": 600, + }, + "max_memory_mb": { + "type": "integer", + "required": False, + "default": 512, + "label": "Max Memory (MB)", + "description": "Address-space memory limit for the local child process. Unit: MB.", + "min": 1, + "max": 65536, + }, + "max_output_bytes": { + "type": "integer", + "required": False, + "default": 1048576, + "label": "Max Output (bytes)", + "description": "Maximum combined stdout and stderr size. Unit: bytes.", + "min": 1024, + "max": 10485760, + }, + "max_artifacts": { + "type": "integer", + "required": False, + "default": 20, + "label": "Max Artifacts", + "description": "Maximum number of files collected from the artifacts directory.", + "min": 0, + "max": 100, + }, + "max_artifact_bytes": { + "type": "integer", + "required": False, + "default": 10485760, + "label": "Max Artifact Size (bytes)", + "description": "Maximum size of a single artifact file. Unit: bytes.", + "min": 1024, + "max": 104857600, + }, } def _validate_limits(self) -> None: @@ -227,13 +276,6 @@ class LocalProvider(SandboxProvider): return [self.node_bin, str(script_path)], script_path raise RuntimeError(f"Unsupported language for local provider: {language}") - @staticmethod - def _resolve_config_value(config: Dict[str, Any], key: str, env_name: str, default: Any) -> Any: - value = config.get(key) - if value is not None: - return value - return os.environ.get(env_name, default) - def _build_child_env(self, instance_dir: Path) -> dict[str, str]: env = { "HOME": str(instance_dir), diff --git a/agent/sandbox/providers/self_managed.py b/agent/sandbox/providers/self_managed.py index 0e73e2f9e..8b92d0b2c 100644 --- a/agent/sandbox/providers/self_managed.py +++ b/agent/sandbox/providers/self_managed.py @@ -22,6 +22,7 @@ a pool of Docker containers with gVisor for secure code execution. """ import base64 +import os import time import uuid from typing import Dict, Any, List, Optional @@ -40,10 +41,10 @@ class SelfManagedProvider(SandboxProvider): """ def __init__(self): - self.endpoint: str = "http://localhost:9385" + self.endpoint: str = "http://sandbox-executor-manager:9385" self.timeout: int = 30 self.max_retries: int = 3 - self.pool_size: int = 10 + self.pool_size: int = 3 self._initialized: bool = False def initialize(self, config: Dict[str, Any]) -> bool: @@ -52,7 +53,7 @@ class SelfManagedProvider(SandboxProvider): Args: config: Configuration dictionary with keys: - - endpoint: HTTP endpoint (default: "http://localhost:9385") + - endpoint: HTTP endpoint (default: "http://sandbox-executor-manager:9385") - timeout: Request timeout in seconds (default: 30) - max_retries: Maximum retry attempts (default: 3) - pool_size: Container pool size for info (default: 10) @@ -60,30 +61,13 @@ class SelfManagedProvider(SandboxProvider): Returns: True if initialization successful, False otherwise """ - self.endpoint = config.get("endpoint", "http://localhost:9385") + self.endpoint = config.get("endpoint", "http://sandbox-executor-manager:9385") self.timeout = config.get("timeout", 30) self.max_retries = config.get("max_retries", 3) - self.pool_size = config.get("pool_size", 10) + self.pool_size = config.get("executor_manager_pool_size", config.get("pool_size", 3)) # Validate endpoint is accessible if not self.health_check(): - # Try to fall back to SANDBOX_HOST from settings if we are using localhost - if "localhost" in self.endpoint or "127.0.0.1" in self.endpoint: - try: - from common import settings - if settings.SANDBOX_HOST and settings.SANDBOX_HOST not in self.endpoint: - original_endpoint = self.endpoint - self.endpoint = f"http://{settings.SANDBOX_HOST}:9385" - if self.health_check(): - import logging - logging.warning(f"Sandbox self_managed: Connected using settings.SANDBOX_HOST fallback: {self.endpoint} (original: {original_endpoint})") - self._initialized = True - return True - else: - self.endpoint = original_endpoint # Restore if fallback also fails - except ImportError: - pass - return False self._initialized = True @@ -270,9 +254,11 @@ class SelfManagedProvider(SandboxProvider): "type": "string", "required": True, "label": "Executor Manager Endpoint", - "placeholder": "http://localhost:9385", - "default": "http://localhost:9385", - "description": "HTTP endpoint of the executor_manager service" + "placeholder": "http://sandbox-executor-manager:9385", + "default": "http://sandbox-executor-manager:9385", + "description": "HTTP endpoint used by RAGFlow to call sandbox-executor-manager.", + "scope": "runtime", + "readonly": False, }, "timeout": { "type": "integer", @@ -281,26 +267,86 @@ class SelfManagedProvider(SandboxProvider): "default": 30, "min": 5, "max": 300, - "description": "HTTP request timeout for code execution" + "description": "Maximum request time for a single code execution call. Unit: seconds.", + "scope": "runtime", + "readonly": False, }, - "max_retries": { - "type": "integer", + "executor_manager_image": { + "type": "string", "required": False, - "label": "Max Retries", - "default": 3, - "min": 0, - "max": 10, - "description": "Maximum number of retry attempts for failed requests" + "label": "Executor Manager Image", + "default": os.getenv("SANDBOX_EXECUTOR_MANAGER_IMAGE", "infiniflow/sandbox-executor-manager:latest"), + "description": "Docker image used by sandbox-executor-manager.", + "scope": "deployment", + "readonly": True, }, - "pool_size": { + "executor_manager_pool_size": { "type": "integer", "required": False, "label": "Container Pool Size", - "default": 10, + "default": int(os.getenv("SANDBOX_EXECUTOR_MANAGER_POOL_SIZE", "3")), "min": 1, "max": 100, - "description": "Size of the container pool (configured in executor_manager)" - } + "description": "Container pool size used by sandbox-executor-manager.", + "scope": "deployment", + "readonly": True, + }, + "base_python_image": { + "type": "string", + "required": False, + "label": "Base Python Image", + "default": os.getenv("SANDBOX_BASE_PYTHON_IMAGE", "infiniflow/sandbox-base-python:latest"), + "description": "Python runtime image used by executor-managed containers.", + "scope": "deployment", + "readonly": True, + }, + "base_nodejs_image": { + "type": "string", + "required": False, + "label": "Base Node.js Image", + "default": os.getenv("SANDBOX_BASE_NODEJS_IMAGE", "infiniflow/sandbox-base-nodejs:latest"), + "description": "Node.js runtime image used by executor-managed containers.", + "scope": "deployment", + "readonly": True, + }, + "executor_manager_port": { + "type": "integer", + "required": False, + "label": "Executor Manager Port", + "default": int(os.getenv("SANDBOX_EXECUTOR_MANAGER_PORT", "9385")), + "min": 1, + "max": 65535, + "description": "Host port exposed by sandbox-executor-manager.", + "scope": "deployment", + "readonly": True, + }, + "enable_seccomp": { + "type": "boolean", + "required": False, + "label": "Enable Seccomp", + "default": os.getenv("SANDBOX_ENABLE_SECCOMP", "false").lower() == "true", + "description": "Whether sandbox-executor-manager starts containers with seccomp enabled.", + "scope": "deployment", + "readonly": True, + }, + "max_memory": { + "type": "string", + "required": False, + "label": "Max Memory", + "default": os.getenv("SANDBOX_MAX_MEMORY", "256m"), + "description": "Memory limit applied to each sandbox container. Common format: 256m or 1g.", + "scope": "deployment", + "readonly": True, + }, + "sandbox_timeout": { + "type": "string", + "required": False, + "label": "Sandbox Timeout", + "default": os.getenv("SANDBOX_TIMEOUT", "10s"), + "description": "Executor-manager container timeout for each sandbox run. Common format: 10s or 1m.", + "scope": "deployment", + "readonly": True, + }, } def _normalize_language(self, language: str) -> str: @@ -347,7 +393,7 @@ class SelfManagedProvider(SandboxProvider): return False, f"Invalid endpoint format: {endpoint}. Must start with http:// or https://" # Validate pool_size is positive - pool_size = config.get("pool_size", 10) + pool_size = config.get("executor_manager_pool_size", config.get("pool_size", 3)) if isinstance(pool_size, int) and pool_size <= 0: return False, "Pool size must be greater than 0" diff --git a/agent/sandbox/providers/ssh.py b/agent/sandbox/providers/ssh.py new file mode 100644 index 000000000..131e4ae8c --- /dev/null +++ b/agent/sandbox/providers/ssh.py @@ -0,0 +1,664 @@ +# +# Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +import base64 +import io +import json +import mimetypes +import os +import posixpath +import shlex +import stat +import time +import uuid +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from agent.sandbox.result_protocol import ( + build_javascript_wrapper, + build_python_wrapper, + extract_structured_result, +) +from .base import ( + ExecutionResult, + SandboxInstance, + SandboxProvider, + SandboxProviderConfigError, +) + +if TYPE_CHECKING: + import paramiko + + +ALLOWED_ARTIFACT_EXTENSIONS = { + ".csv", + ".html", + ".jpeg", + ".jpg", + ".json", + ".pdf", + ".png", + ".svg", +} + + +class SSHProvider(SandboxProvider): + """Execute code on a remote host through SSH.""" + + def __init__(self): + self.host = "" + self.port = 22 + self.username = "" + self.password = "" + self.private_key = "" + self.passphrase = "" + self.python_bin = "python3" + self.node_bin = "node" + self.work_dir = "/tmp" + self.timeout = 30 + self.max_output_bytes = 1024 * 1024 + self.max_artifacts = 20 + self.max_artifact_bytes = 10 * 1024 * 1024 + self._initialized = False + self._instances: dict[str, dict[str, Any]] = {} + + def initialize(self, config: Dict[str, Any]) -> bool: + self.host = str(config.get("host", "")).strip() + self.port = int(config.get("port", 22) or 22) + self.username = str(config.get("username", "")).strip() + self.password = str(config.get("password", "") or "") + self.private_key = str(config.get("private_key", "") or "") + self.passphrase = str(config.get("passphrase", "") or "") + self.python_bin = str(config.get("python_bin", "python3") or "python3").strip() or "python3" + self.node_bin = str(config.get("node_bin", "node") or "node").strip() or "node" + self.work_dir = str(config.get("work_dir", "/tmp") or "/tmp").strip() or "/tmp" + self.timeout = int(config.get("timeout", 30) or 30) + self.max_output_bytes = int(config.get("max_output_bytes", 1024 * 1024) or 1024 * 1024) + self.max_artifacts = int(config.get("max_artifacts", 20) or 20) + self.max_artifact_bytes = int(config.get("max_artifact_bytes", 10 * 1024 * 1024) or 10 * 1024 * 1024) + + is_valid, error_message = self.validate_config( + { + "host": self.host, + "port": self.port, + "username": self.username, + "password": self.password, + "private_key": self.private_key, + "passphrase": self.passphrase, + "python_bin": self.python_bin, + "node_bin": self.node_bin, + "work_dir": self.work_dir, + "timeout": self.timeout, + "max_output_bytes": self.max_output_bytes, + "max_artifacts": self.max_artifacts, + "max_artifact_bytes": self.max_artifact_bytes, + } + ) + if not is_valid: + raise SandboxProviderConfigError(error_message or "Invalid SSH provider configuration.") + + self._assert_connectivity() + + self._initialized = True + return True + + def create_instance(self, template: str = "python") -> SandboxInstance: + if not self._initialized: + raise RuntimeError("Provider not initialized. Call initialize() first.") + + language = self._normalize_language(template) + client = self._create_ssh_client() + sftp = client.open_sftp() + + try: + remote_work_dir = self._create_remote_workspace(client) + stdout, stderr, exit_code = self._run_remote_command( + client, + f"mkdir -p {shlex.quote(posixpath.join(remote_work_dir, 'artifacts'))}", + timeout=min(self.timeout, 10), + ) + if exit_code != 0: + raise RuntimeError( + f"Failed to create remote artifacts directory: {stderr or stdout or 'unknown error'}" + ) + except Exception: + sftp.close() + client.close() + raise + + instance_id = str(uuid.uuid4()) + self._instances[instance_id] = { + "client": client, + "sftp": sftp, + "remote_work_dir": remote_work_dir, + "language": language, + } + + return SandboxInstance( + instance_id=instance_id, + provider="ssh", + status="running", + metadata={"language": language, "remote_work_dir": remote_work_dir}, + ) + + def execute_code( + self, + instance_id: str, + code: str, + language: str, + timeout: int = 10, + arguments: Optional[Dict[str, Any]] = None, + ) -> ExecutionResult: + if not self._initialized: + raise RuntimeError("Provider not initialized. Call initialize() first.") + if instance_id not in self._instances: + raise RuntimeError(f"Unknown SSH sandbox instance: {instance_id}") + + normalized_lang = self._normalize_language(language) + instance = self._instances[instance_id] + client: paramiko.SSHClient = instance["client"] + sftp: paramiko.SFTPClient = instance["sftp"] + remote_work_dir: str = instance["remote_work_dir"] + + args_json = json.dumps(arguments or {}, ensure_ascii=False) + remote_script_path, command = self._upload_script( + sftp=sftp, + remote_work_dir=remote_work_dir, + language=normalized_lang, + code=code, + args_json=args_json, + ) + + requested_timeout = self.timeout if timeout is None else int(timeout) + if requested_timeout <= 0: + raise RuntimeError(f"Execution timeout must be greater than 0 seconds, got {requested_timeout}.") + exec_timeout = min(requested_timeout, self.timeout) + + start_time = time.time() + stdout, stderr, exit_code = self._run_remote_command(client, command, timeout=exec_timeout) + execution_time = time.time() - start_time + + self._validate_output_size(stdout, stderr) + stdout, structured_result = extract_structured_result(stdout) + + return ExecutionResult( + stdout=stdout, + stderr=stderr, + exit_code=exit_code, + execution_time=execution_time, + metadata={ + "instance_id": instance_id, + "language": normalized_lang, + "script_path": remote_script_path, + "remote_work_dir": remote_work_dir, + "status": "ok" if exit_code == 0 else "error", + "timeout": exec_timeout, + "command": command, + "artifacts": self._collect_artifacts( + sftp, posixpath.join(remote_work_dir, "artifacts") + ), + "result_present": structured_result.get("present", False), + "result_value": structured_result.get("value"), + "result_type": structured_result.get("type"), + }, + ) + + def destroy_instance(self, instance_id: str) -> bool: + if not self._initialized: + raise RuntimeError("Provider not initialized. Call initialize() first.") + if instance_id not in self._instances: + return True + + instance = self._instances.pop(instance_id) + client: paramiko.SSHClient = instance["client"] + sftp: paramiko.SFTPClient = instance["sftp"] + remote_work_dir: str = instance["remote_work_dir"] + + cleanup_error: Optional[Exception] = None + try: + stdout, stderr, exit_code = self._run_remote_command( + client, + f"rm -rf {shlex.quote(remote_work_dir)}", + timeout=min(self.timeout, 10), + ) + if exit_code != 0: + raise RuntimeError(stderr or stdout or "unknown error") + except Exception as exc: + cleanup_error = exc + finally: + try: + sftp.close() + finally: + client.close() + + if cleanup_error is not None: + raise RuntimeError(f"Failed to clean remote workspace {remote_work_dir}: {cleanup_error}") + return True + + def health_check(self) -> bool: + try: + self._assert_connectivity() + return True + except Exception: + return False + + def _assert_connectivity(self) -> None: + try: + client = self._create_ssh_client() + try: + _, stderr, exit_code = self._run_remote_command( + client, + "true", + timeout=min(self.timeout, 10), + ) + if exit_code != 0: + raise SandboxProviderConfigError( + f"SSH connectivity check failed on {self.username}@{self.host}:{self.port}: " + f"{stderr or 'remote command returned non-zero exit status'}" + ) + finally: + client.close() + except SandboxProviderConfigError: + raise + except Exception as exc: + raise SandboxProviderConfigError( + f"Failed to connect to SSH host {self.username}@{self.host}:{self.port}: {exc}" + ) from exc + + def get_supported_languages(self) -> List[str]: + return ["python", "javascript", "nodejs"] + + @staticmethod + def get_config_schema() -> Dict[str, Dict]: + return { + "host": { + "type": "string", + "required": True, + "label": "SSH Host", + "placeholder": "192.168.1.10", + "description": "Remote host that will execute generated code.", + }, + "port": { + "type": "integer", + "required": True, + "label": "SSH Port", + "default": 22, + "min": 1, + "max": 65535, + "description": "SSH port on the remote host.", + }, + "username": { + "type": "string", + "required": True, + "label": "SSH Username", + "placeholder": "ragflow", + "description": "Username used to connect to the remote host.", + }, + "password": { + "type": "string", + "required": False, + "label": "SSH Password", + "secret": True, + "placeholder": "Optional when using a private key", + "description": "Password-based SSH authentication.", + }, + "private_key": { + "type": "string", + "required": False, + "label": "SSH Private Key", + "secret": True, + "multiline": True, + "placeholder": "Paste PEM content or enter a local file path", + "description": "Private key PEM content or a readable private key path on the RAGFlow host.", + }, + "passphrase": { + "type": "string", + "required": False, + "label": "Private Key Passphrase", + "secret": True, + "placeholder": "Optional", + "description": "Passphrase for the private key if it is encrypted.", + }, + "python_bin": { + "type": "string", + "required": False, + "default": "python3", + "label": "Python Binary", + "description": "Python executable used for remote code execution.", + }, + "node_bin": { + "type": "string", + "required": False, + "default": "node", + "label": "Node.js Binary", + "description": "Node.js executable used for remote JavaScript execution.", + }, + "work_dir": { + "type": "string", + "required": False, + "label": "Remote Workspace Root", + "default": "/tmp", + "placeholder": "/tmp", + "description": "Writable remote directory used to create a temporary workspace.", + }, + "timeout": { + "type": "integer", + "required": False, + "label": "Timeout (seconds)", + "default": 30, + "min": 1, + "max": 600, + "description": "Maximum SSH execution time for a single run.", + }, + "max_output_bytes": { + "type": "integer", + "required": False, + "label": "Max Output Bytes", + "default": 1048576, + "min": 1024, + "max": 10485760, + "description": "Maximum combined stdout and stderr size.", + }, + "max_artifacts": { + "type": "integer", + "required": False, + "label": "Max Artifacts", + "default": 20, + "min": 0, + "max": 100, + "description": "Maximum number of files collected from the remote artifacts directory.", + }, + "max_artifact_bytes": { + "type": "integer", + "required": False, + "label": "Max Artifact Bytes", + "default": 10485760, + "min": 1024, + "max": 104857600, + "description": "Maximum size of a single artifact file in bytes.", + }, + } + + def validate_config(self, config: Dict[str, Any]) -> tuple[bool, Optional[str]]: + host = str(config.get("host", "") or "").strip() + username = str(config.get("username", "") or "").strip() + password = str(config.get("password", "") or "") + private_key = str(config.get("private_key", "") or "") + python_bin = str(config.get("python_bin", "python3") or "python3").strip() + node_bin = str(config.get("node_bin", "node") or "node").strip() + + if not host: + return False, "SSH host is required" + if not username: + return False, "SSH username is required" + if not password and not private_key: + return False, "Either password or private_key must be provided" + if not python_bin: + return False, "Python binary is required" + if not node_bin: + return False, "Node.js binary is required" + + try: + port = int(config.get("port", 22) or 22) + except (TypeError, ValueError): + return False, "SSH port must be an integer" + if port <= 0 or port > 65535: + return False, "SSH port must be between 1 and 65535" + + for key in ("timeout", "max_output_bytes", "max_artifacts", "max_artifact_bytes"): + try: + value = int(config.get(key, 0) or 0) + except (TypeError, ValueError): + return False, f"{key} must be an integer" + if key == "max_artifacts": + if value < 0: + return False, "max_artifacts must be greater than or equal to 0" + elif value <= 0: + return False, f"{key} must be greater than 0" + + return True, None + + def _create_ssh_client(self) -> paramiko.SSHClient: + paramiko = _get_paramiko_module() + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + connect_kwargs: dict[str, Any] = { + "hostname": self.host, + "port": self.port, + "username": self.username, + "timeout": self.timeout, + "banner_timeout": self.timeout, + "auth_timeout": self.timeout, + "look_for_keys": False, + "allow_agent": False, + } + if self.private_key: + connect_kwargs["pkey"] = self._load_private_key() + if self.password: + connect_kwargs["password"] = self.password + + client.connect(**connect_kwargs) + return client + + def _load_private_key(self) -> paramiko.PKey: + paramiko = _get_paramiko_module() + loaders = ( + paramiko.RSAKey, + paramiko.Ed25519Key, + paramiko.ECDSAKey, + paramiko.DSSKey, + ) + errors: list[str] = [] + private_key_value = self.private_key.strip() + passphrase = self.passphrase or None + + if os.path.exists(private_key_value): + for key_cls in loaders: + try: + return key_cls.from_private_key_file(private_key_value, password=passphrase) + except Exception as exc: + errors.append(str(exc)) + else: + for key_cls in loaders: + try: + return key_cls.from_private_key(io.StringIO(private_key_value), password=passphrase) + except Exception as exc: + errors.append(str(exc)) + + raise SandboxProviderConfigError( + "Failed to load SSH private key. " + "; ".join(error for error in errors if error) + ) + + def _create_remote_workspace(self, client: paramiko.SSHClient) -> str: + base_dir = self.work_dir.rstrip("/") or "/tmp" + template = posixpath.join(base_dir, "ragflow-codeexec.XXXXXX") + stdout, stderr, exit_code = self._run_remote_command( + client, + f"mkdir -p {shlex.quote(base_dir)} && mktemp -d {shlex.quote(template)}", + timeout=min(self.timeout, 10), + ) + if exit_code != 0: + raise RuntimeError( + f"Failed to create remote workspace on {self.host}: {stderr or stdout or 'unknown error'}" + ) + + remote_work_dir = stdout.strip().splitlines()[-1] if stdout.strip() else "" + if not remote_work_dir: + raise RuntimeError("Remote workspace creation did not return a path.") + return remote_work_dir + + def _upload_script( + self, + sftp: paramiko.SFTPClient, + remote_work_dir: str, + language: str, + code: str, + args_json: str, + ) -> tuple[str, str]: + if language == "python": + script_name = "main.py" + script_content = build_python_wrapper(code, args_json) + elif language in {"javascript", "nodejs"}: + script_name = "main.js" + script_content = build_javascript_wrapper(code, args_json) + else: + raise RuntimeError(f"Unsupported language for SSH provider: {language}") + + remote_script_path = posixpath.join(remote_work_dir, script_name) + with sftp.file(remote_script_path, "w") as remote_file: + remote_file.write(script_content) + + command = self._build_execution_command(remote_work_dir, remote_script_path, language) + return remote_script_path, command + + def _build_execution_command(self, remote_work_dir: str, remote_script_path: str, language: str) -> str: + normalized_lang = self._normalize_language(language) + if normalized_lang == "python": + executable = self.python_bin + elif normalized_lang == "nodejs": + executable = self.node_bin + else: + raise RuntimeError(f"Unsupported language for SSH provider: {language}") + + return ( + f"cd {shlex.quote(remote_work_dir)} && " + f"{shlex.quote(executable)} {shlex.quote(remote_script_path)}" + ) + + def _run_remote_command( + self, + client: paramiko.SSHClient, + command: str, + timeout: int, + ) -> tuple[str, str, int]: + stdin, stdout_stream, stderr_stream = client.exec_command(command, timeout=timeout) + stdin.close() + channel = stdout_stream.channel + + stdout_chunks: list[bytes] = [] + stderr_chunks: list[bytes] = [] + deadline = time.time() + timeout + + while True: + while channel.recv_ready(): + stdout_chunks.append(channel.recv(65536)) + while channel.recv_stderr_ready(): + stderr_chunks.append(channel.recv_stderr(65536)) + + if channel.exit_status_ready(): + break + if time.time() > deadline: + channel.close() + raise TimeoutError(f"Execution timed out after {timeout} seconds") + time.sleep(0.1) + + while channel.recv_ready(): + stdout_chunks.append(channel.recv(65536)) + while channel.recv_stderr_ready(): + stderr_chunks.append(channel.recv_stderr(65536)) + + exit_code = channel.recv_exit_status() + stdout = b"".join(stdout_chunks).decode("utf-8", errors="replace") + stderr = b"".join(stderr_chunks).decode("utf-8", errors="replace") + return stdout, stderr, exit_code + + def _validate_output_size(self, stdout: str, stderr: str) -> None: + output_size = len((stdout or "").encode("utf-8")) + len((stderr or "").encode("utf-8")) + if output_size > self.max_output_bytes: + raise RuntimeError(f"SSH execution output exceeded {self.max_output_bytes} bytes.") + + def _collect_artifacts( + self, + sftp: paramiko.SFTPClient, + artifacts_dir: str, + ) -> list[dict[str, Any]]: + artifacts: list[dict[str, Any]] = [] + self._collect_artifacts_recursive(sftp, artifacts_dir, "", artifacts) + return artifacts + + def _collect_artifacts_recursive( + self, + sftp: paramiko.SFTPClient, + current_dir: str, + relative_dir: str, + artifacts: list[dict[str, Any]], + ) -> None: + try: + entries = sftp.listdir_attr(current_dir) + except FileNotFoundError: + return + + for entry in sorted(entries, key=lambda item: item.filename): + name = entry.filename + remote_path = posixpath.join(current_dir, name) + relative_path = posixpath.join(relative_dir, name) if relative_dir else name + mode = entry.st_mode + if mode is None: + mode = sftp.lstat(remote_path).st_mode + if mode is None: + raise RuntimeError(f"Unable to determine artifact entry type: {relative_path}") + + if stat.S_ISLNK(mode): + raise RuntimeError(f"Artifact symlinks are not allowed: {relative_path}") + if stat.S_ISDIR(mode): + self._collect_artifacts_recursive(sftp, remote_path, relative_path, artifacts) + continue + if not stat.S_ISREG(mode): + raise RuntimeError(f"Unsupported artifact entry: {relative_path}") + + if len(artifacts) >= self.max_artifacts: + raise RuntimeError(f"SSH execution produced more than {self.max_artifacts} artifacts.") + + size = int(entry.st_size or 0) + if size > self.max_artifact_bytes: + raise RuntimeError(f"Artifact exceeds {self.max_artifact_bytes} bytes: {relative_path}") + + ext = os.path.splitext(name)[1].lower() + if ext not in ALLOWED_ARTIFACT_EXTENSIONS: + raise RuntimeError(f"Unsupported artifact type: {relative_path}") + + with sftp.file(remote_path, "rb") as artifact_file: + content = artifact_file.read() + + artifacts.append( + { + "name": relative_path, + "content_b64": base64.b64encode(content).decode("ascii"), + "mime_type": mimetypes.guess_type(name)[0] or "application/octet-stream", + "size": size, + } + ) + + @staticmethod + def _normalize_language(language: str) -> str: + lang_lower = (language or "python").lower() + if lang_lower in {"python", "python3"}: + return "python" + if lang_lower in {"javascript", "nodejs"}: + return "nodejs" + return lang_lower + + +def _get_paramiko_module(): + try: + import paramiko + except ImportError as exc: + raise SandboxProviderConfigError( + "paramiko is required for the SSH sandbox provider. Install the project dependencies to enable it." + ) from exc + return paramiko diff --git a/agent/tools/base.py b/agent/tools/base.py index dbcb18551..71cf2c593 100644 --- a/agent/tools/base.py +++ b/agent/tools/base.py @@ -19,6 +19,7 @@ import time from copy import deepcopy import asyncio from functools import partial +from collections.abc import Mapping from typing import TypedDict, List, Any from agent.component.base import ComponentParamBase, ComponentBase from common.misc_utils import hash_str2int @@ -58,6 +59,8 @@ class LLMToolPluginCallSession(ToolCallSession): async def tool_call_async(self, name: str, arguments: dict[str, Any], request_timeout: float | int = 10) -> Any: assert name in self.tools_map, f"LLM tool {name} does not exist" logging.info(f"[ToolCall] invoke name={name} arguments={str(arguments)[:200]}") + if not isinstance(arguments, Mapping): + raise TypeError(f"Tool arguments for {name} must be an object, got {type(arguments).__name__}") st = timer() tool_obj = self.tools_map[name] if isinstance(tool_obj, MCPToolBinding): diff --git a/agent/tools/code_exec.py b/agent/tools/code_exec.py index f748f31b5..3133784e2 100644 --- a/agent/tools/code_exec.py +++ b/agent/tools/code_exec.py @@ -361,11 +361,21 @@ class CodeExec(ToolBase, ABC): # Try using the new sandbox provider system first try: from agent.sandbox.client import execute_code as sandbox_execute_code + from agent.sandbox.client import get_provider_info + from agent.sandbox.client import reload_provider from agent.sandbox.providers.base import SandboxProviderConfigError if self.check_if_canceled("CodeExec execution"): return + reload_provider() + provider_info = get_provider_info() + provider_type = provider_info.get("provider_type") or "unknown" + logging.info( + f"[CodeExec]: dispatching execution to sandbox provider '{provider_type}' " + f"(language={language}, timeout={timeout_seconds}s)" + ) + # Execute code using the provider system result = sandbox_execute_code(code=code, language=language, timeout=timeout_seconds, arguments=arguments) @@ -376,7 +386,7 @@ class CodeExec(ToolBase, ABC): return self._process_execution_result( result.stdout, result.stderr, - "Provider system", + f"Provider system ({provider_type})", artifacts, execution_metadata=result.metadata, ) @@ -388,10 +398,8 @@ class CodeExec(ToolBase, ABC): # Provider modules are unavailable, fall back to legacy HTTP sandbox. logging.info(f"[CodeExec]: Provider system not available, using HTTP fallback: {provider_error}") except RuntimeError as provider_error: - if not self._should_fallback_to_http(provider_error): - self.set_output("_ERROR", f"Provider system execution failed: {provider_error}") - return self.output() - logging.info(f"[CodeExec]: Provider system not available, using HTTP fallback: {provider_error}") + self.set_output("_ERROR", f"Provider system execution failed: {provider_error}") + return self.output() # Fallback to direct HTTP request code_b64 = self._encode_code(code) @@ -502,15 +510,6 @@ class CodeExec(ToolBase, ABC): return metadata.get("result_value"), False return self._deserialize_stdout(stdout), True - @staticmethod - def _should_fallback_to_http(provider_error: RuntimeError) -> bool: - message = str(provider_error).lower() - fallback_markers = ( - "no sandbox provider configured", - "sandbox provider type not configured", - ) - return any(marker in message for marker in fallback_markers) - @classmethod def _ensure_bucket_lifecycle(cls): if cls._lifecycle_configured: diff --git a/conf/llm_factories.json b/conf/llm_factories.json index e8d02ed88..4a98f2ccc 100644 --- a/conf/llm_factories.json +++ b/conf/llm_factories.json @@ -2262,13 +2262,6 @@ "model_type": "chat", "is_tools": true }, - { - "llm_name": "qwen/qwen2.5-coder-32b-instruct", - "tags": "LLM,CHAT,32K", - "max_tokens": 32768, - "model_type": "chat", - "is_tools": true - }, { "llm_name": "rakuten/rakutenai-7b-chat", "tags": "LLM,CHAT,4K", @@ -2843,13 +2836,6 @@ "rank": "780", "url": "https://api.siliconflow.cn/v1", "llm": [ - { - "llm_name": "THUDM/GLM-4.1V-9B-Thinking", - "tags": "LLM,CHAT,IMAGE2TEXT, 64k", - "max_tokens": 64000, - "model_type": "chat", - "is_tools": false - }, { "llm_name": "Qwen/Qwen3-Embedding-8B", "tags": "TEXT EMBEDDING,TEXT RE-RANK,32k", @@ -2906,13 +2892,6 @@ "model_type": "chat", "is_tools": true }, - { - "llm_name": "Qwen/QVQ-72B-Preview", - "tags": "LLM,CHAT,IMAGE2TEXT,32k", - "max_tokens": 32000, - "model_type": "image2text", - "is_tools": false - }, { "llm_name": "Pro/deepseek-ai/DeepSeek-R1", "tags": "LLM,CHAT,64k", @@ -2983,20 +2962,6 @@ "model_type": "chat", "is_tools": true }, - { - "llm_name": "Qwen/Qwen2.5-VL-72B-Instruct", - "tags": "LLM,CHAT,IMAGE2TEXT,128k", - "max_tokens": 128000, - "model_type": "image2text", - "is_tools": true - }, - { - "llm_name": "Pro/Qwen/Qwen2.5-VL-7B-Instruct", - "tags": "LLM,CHAT,IMAGE2TEXT,32k", - "max_tokens": 32000, - "model_type": "image2text", - "is_tools": false - }, { "llm_name": "THUDM/GLM-Z1-32B-0414", "tags": "LLM,CHAT,32k", @@ -3046,20 +3011,6 @@ "model_type": "chat", "is_tools": true }, - { - "llm_name": "Qwen/Qwen2.5-Coder-32B-Instruct", - "tags": "LLM,CHAT,32k", - "max_tokens": 32000, - "model_type": "chat", - "is_tools": false - }, - { - "llm_name": "Qwen/Qwen2-VL-72B-Instruct", - "tags": "LLM,IMAGE2TEXT,32k", - "max_tokens": 32000, - "model_type": "image2text", - "is_tools": false - }, { "llm_name": "Qwen/Qwen2.5-72B-Instruct-128Kt", "tags": "LLM,IMAGE2TEXT,128k", @@ -3664,13 +3615,6 @@ "model_type": "chat", "is_tools": true }, - { - "llm_name": "Qwen/Qwen2.5-VL-32B-Instruct", - "tags": "LLM,CHAT,131k", - "max_tokens": 131000, - "model_type": "chat", - "is_tools": true - }, { "llm_name": "Qwen/QwQ-32B", "tags": "LLM,CHAT,131k", @@ -3678,20 +3622,6 @@ "model_type": "chat", "is_tools": true }, - { - "llm_name": "Qwen/Qwen2.5-VL-72B-Instruct", - "tags": "LLM,CHAT,131k", - "max_tokens": 131000, - "model_type": "chat", - "is_tools": true - }, - { - "llm_name": "Qwen/Qwen2.5-VL-7B-Instruct", - "tags": "LLM,CHAT,33k", - "max_tokens": 33000, - "model_type": "chat", - "is_tools": false - }, { "llm_name": "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B", "tags": "LLM,CHAT,131k", diff --git a/docker/.env b/docker/.env index 9b512c814..e05faac14 100644 --- a/docker/.env +++ b/docker/.env @@ -242,39 +242,23 @@ REGISTER_ENABLED=1 # ----------------------------------------------------------------------------- # Sandbox # ----------------------------------------------------------------------------- -# Sandbox settings are grouped by provider type. -# 1. Set `SANDBOX_ENABLED=1` to enable sandbox support. -# 2. Set `SANDBOX_PROVIDER_TYPE` to choose the active provider. -# 3. Only edit the section that matches the selected provider type. -# 4. If you do not use `self_managed`, remove `,sandbox` from `COMPOSE_PROFILES`. -# -# Naming convention for future providers: -# - `SANDBOX__*` -# Examples: -# - `SANDBOX_SELF_MANAGED_*` -# - `SANDBOX_LOCAL_*` -# - `SANDBOX_E2B_*` -# - `SANDBOX_ALIYUN_CODEINTERPRETER_*` +# Sandbox provider type and runtime settings are configured in Admin > Sandbox +# Settings. # Enable sandbox support. # SANDBOX_ENABLED=1 # COMPOSE_PROFILES=${COMPOSE_PROFILES},sandbox -# SANDBOX_PROVIDER_TYPE=${SANDBOX_PROVIDER_TYPE:-self_managed} # Shared sandbox settings -# `SANDBOX_HOST` is kept as the common endpoint name for legacy HTTP fallback -# and for the self-managed provider. -# Double check that `sandbox-executor-manager` resolves correctly in your -# Docker network or `/etc/hosts`. -# SANDBOX_HOST=${SANDBOX_HOST:-sandbox-executor-manager} # The MinIO bucket name for storing sandbox-generated artifacts. # SANDBOX_ARTIFACT_BUCKET=sandbox-artifacts + # Number of days before sandbox artifacts are automatically deleted. # SANDBOX_ARTIFACT_EXPIRE_DAYS=7 -# Provider: self_managed -# Use this provider when sandbox executors run as Docker services managed by -# RAGFlow. This is the default provider used by the `sandbox` compose profile. +# Self-managed deployment defaults +# These values are used by the `sandbox` compose profile and shown in Admin as +# deployment defaults for the self-managed provider. # Pull the required base images before running: # docker pull infiniflow/sandbox-base-nodejs:latest # docker pull infiniflow/sandbox-base-python:latest @@ -290,29 +274,9 @@ REGISTER_ENABLED=1 # SANDBOX_MAX_MEMORY=256m # b, k, m, g # SANDBOX_TIMEOUT=10s # s, m, 1m30s -# Provider: local -# Use this provider only in trusted development environments. It executes code -# on the local machine instead of inside Docker-managed sandbox containers. -# When `SANDBOX_PROVIDER_TYPE=local`, you usually do not need the `sandbox` -# compose profile. -# Uncomment and adjust only if you use the local provider. -# SANDBOX_LOCAL_ENABLED=true -# SANDBOX_LOCAL_PYTHON_BIN=python3 -# SANDBOX_LOCAL_NODE_BIN=node -# SANDBOX_LOCAL_WORK_DIR=/tmp/ragflow-codeexec -# SANDBOX_LOCAL_TIMEOUT=30 -# SANDBOX_LOCAL_MAX_MEMORY_MB=1024 -# SANDBOX_LOCAL_MAX_OUTPUT_BYTES=1048576 -# SANDBOX_LOCAL_MAX_ARTIFACTS=20 -# SANDBOX_LOCAL_MAX_ARTIFACT_BYTES=10485760 -# Limit native math library threads for local Python subprocesses if NumPy or -# OpenBLAS fails with `pthread_create failed` under tight thread limits. -# OPENBLAS_NUM_THREADS=1 -# OMP_NUM_THREADS=1 -# MKL_NUM_THREADS=1 -# NUMEXPR_NUM_THREADS=1 -# BLIS_NUM_THREADS=1 -# VECLIB_MAXIMUM_THREADS=1 +# ----------------------------------------------------------------------------- +# Sandbox End +# ----------------------------------------------------------------------------- # Enable DocLing USE_DOCLING=false diff --git a/docs/guides/agent/agent_quickstarts/sandbox_quickstart.md b/docs/guides/agent/agent_quickstarts/sandbox_quickstart.md index eff2aaa64..cb6516cdc 100644 --- a/docs/guides/agent/agent_quickstarts/sandbox_quickstart.md +++ b/docs/guides/agent/agent_quickstarts/sandbox_quickstart.md @@ -7,28 +7,36 @@ sidebar_custom_props: { --- # Sandbox quickstart -A secure, pluggable code execution backend designed for RAGFlow and other applications requiring isolated code execution environments. +RAGFlow's `CodeExec` agent component needs a sandbox provider to run Python and JavaScript code. -RAGFlow's `CodeExec` agent component depends on a sandbox provider to run Python and JavaScript code. Configure one of the providers below before using `CodeExec`. +The simplest setup flow is: -## Features: +1. Start the required sandbox services. +2. Open the RAGFlow admin page. +3. Go to **Admin > Sandbox Settings**. +4. Choose a provider and save the configuration. +5. Test the connection in the same page. -- Seamless RAGFlow Integration — Works out-of-the-box with the code component of RAGFlow. -- High Security — Uses gVisor for syscall-level sandboxing to isolate execution. -- Customisable Sandboxing — Modify seccomp profiles easily to tailor syscall restrictions. -- Pluggable Runtime Support — Extendable to support any programming language runtime. -- Developer Friendly — Quick setup with a convenient Makefile. +## Admin page -## Architecture +Configure sandbox providers from the admin page: -The architecture consists of isolated Docker base images for each supported language runtime, managed by the executor manager service. The executor manager orchestrates sandboxed code execution using gVisor for syscall interception and optional seccomp profiles for enhanced syscall filtering. +- `self_managed`: Uses the executor manager service. +- `local`: Runs code on the current machine. +- `ssh`: Runs code on a remote machine over SSH. +- `aliyun_codeinterpreter` and `e2b`: Cloud providers. + +Sandbox Settings Screenshot ## Provider options -RAGFlow supports two sandbox provider types: +RAGFlow supports multiple sandbox providers. Configure the active provider in +Admin > Sandbox Settings after the services are up. -- `self_managed`: Runs code inside Docker-managed sandbox containers. Use this for the standard RAGFlow sandbox deployment. +- `self_managed`: Runs code inside Docker-managed sandbox containers. This is the default provider. - `local`: Runs code as local Python or Node.js subprocesses. Use this only in trusted development environments. +- `ssh`: Runs code on a remote machine over SSH. +- `aliyun_codeinterpreter` and `e2b`: Cloud-hosted providers that remain available in the admin provider list. ## Prerequisites @@ -94,9 +102,11 @@ docker compose -f docker-compose.yml up -d 2. Configure the .env file located at docker/.env: - Set `SANDBOX_ENABLED=1`. -- Set `SANDBOX_PROVIDER_TYPE=self_managed` or `SANDBOX_PROVIDER_TYPE=local`. -- For `self_managed`, include `sandbox` in `COMPOSE_PROFILES`. -- For `local`, uncomment and adjust the `SANDBOX_LOCAL_*` variables. +- Include `sandbox` in `COMPOSE_PROFILES` if you want the default + `self_managed` executor-manager service. +- Keep the self-managed deployment defaults in `.env` if you need to change the + sandbox-executor-manager image, pool size, base images, seccomp, memory, or + timeout. 3. Add the following entry to your /etc/hosts file to resolve the executor manager service: @@ -105,26 +115,30 @@ docker compose -f docker-compose.yml up -d ``` 4. Start the RAGFlow service as usual. +5. Open **Admin > Sandbox Settings**. +6. Select a provider. +7. Fill in the required fields. +8. Click **Save**. +9. Click **Test Connection** if needed. ## Environment variables The variables in `docker/.env` are grouped by scope. -### Shared variables +### System-level variables These variables apply to sandbox support in general: - `SANDBOX_ENABLED`: Enables sandbox support in RAGFlow. -- `SANDBOX_PROVIDER_TYPE`: Selects the active provider. Supported values are `self_managed` and `local`. -- `SANDBOX_HOST`: The executor manager host used by the self-managed provider and the legacy HTTP fallback. +- `COMPOSE_PROFILES`: Include `sandbox` to start the default self-managed executor-manager service. - `SANDBOX_ARTIFACT_BUCKET`: MinIO bucket used for files generated by sandbox code. - `SANDBOX_ARTIFACT_EXPIRE_DAYS`: Number of days before sandbox artifacts expire. -### Self-managed variables +### Self-managed deployment defaults -These variables apply when `SANDBOX_PROVIDER_TYPE=self_managed`: +These variables are shown in Admin as deployment defaults for `self_managed`. +Changing them requires restarting `sandbox-executor-manager`. -- `COMPOSE_PROFILES`: Must include `sandbox` to start `sandbox-executor-manager` with RAGFlow. - `SANDBOX_EXECUTOR_MANAGER_IMAGE`: Docker image for the executor manager service. - `SANDBOX_EXECUTOR_MANAGER_POOL_SIZE`: Number of Python and Node.js sandbox containers kept in the pool. - `SANDBOX_BASE_PYTHON_IMAGE`: Python runtime image used by executor-managed containers. @@ -134,20 +148,21 @@ These variables apply when `SANDBOX_PROVIDER_TYPE=self_managed`: - `SANDBOX_MAX_MEMORY`: Memory limit for each sandbox runtime container. - `SANDBOX_TIMEOUT`: Default execution timeout. -### Local variables +### Admin-managed runtime settings -These variables apply when `SANDBOX_PROVIDER_TYPE=local`: +Provider selection and runtime settings are configured in **Admin > Sandbox Settings**. -- `SANDBOX_LOCAL_ENABLED`: Explicitly enables local code execution. -- `SANDBOX_LOCAL_PYTHON_BIN`: Python executable used by local execution. -- `SANDBOX_LOCAL_NODE_BIN`: Node.js executable used by local execution. -- `SANDBOX_LOCAL_WORK_DIR`: Working directory for local execution files and artifacts. -- `SANDBOX_LOCAL_TIMEOUT`: Maximum local execution time in seconds. -- `SANDBOX_LOCAL_MAX_MEMORY_MB`: Address-space memory limit for local child processes. -- `SANDBOX_LOCAL_MAX_OUTPUT_BYTES`: Maximum stdout and stderr size. -- `SANDBOX_LOCAL_MAX_ARTIFACTS`: Maximum number of artifacts collected after execution. -- `SANDBOX_LOCAL_MAX_ARTIFACT_BYTES`: Maximum size for each artifact. -- `OPENBLAS_NUM_THREADS`, `OMP_NUM_THREADS`, `MKL_NUM_THREADS`, `NUMEXPR_NUM_THREADS`, `BLIS_NUM_THREADS`, `VECLIB_MAXIMUM_THREADS`: Optional native math library thread limits for local Python subprocesses. +Examples: + +- Choose the active provider +- Configure `self_managed` runtime settings +- Configure all `local` settings +- Configure all `ssh` settings + +For `self_managed`: + +- Runtime settings are editable in Admin +- Deployment defaults come from `.env` and are shown as read-only values ## Running standalone diff --git a/pyproject.toml b/pyproject.toml index fcb27283d..e3ff36830 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,6 +92,7 @@ dependencies = [ "pypdf>=6.10.2", "python-calamine>=0.4.0", "python-docx>=1.1.2,<2.0.0", + "paramiko>=3.5.1", "python-pptx>=1.0.2,<2.0.0", # "pywencai>=0.13.1,<1.0.0", # Temporarily disabled: conflicts with agentrun-sdk (pydash>=8), needed for agent/tools/wencai.py "qianfan==0.4.6", diff --git a/rag/llm/chat_model.py b/rag/llm/chat_model.py index 43022dd32..1d9612dff 100644 --- a/rag/llm/chat_model.py +++ b/rag/llm/chat_model.py @@ -391,6 +391,10 @@ class Base(ABC): name = tc.function.name try: args = json_repair.loads(tc.function.arguments) + if not isinstance(args, dict): + raise TypeError( + f"Tool arguments for {name} must be a JSON object, got {type(args).__name__}" + ) if hasattr(self.toolcall_session, "tool_call_async"): result = await self.toolcall_session.tool_call_async(name, args) else: @@ -493,6 +497,10 @@ class Base(ABC): name = tc.function.name try: args = json_repair.loads(tc.function.arguments) + if not isinstance(args, dict): + raise TypeError( + f"Tool arguments for {name} must be a JSON object, got {type(args).__name__}" + ) if hasattr(self.toolcall_session, "tool_call_async"): result = await self.toolcall_session.tool_call_async(name, args) else: @@ -1495,7 +1503,11 @@ class LiteLLMBase(ABC): return msg def _verbose_tool_use(self, name, args, res): - return "" + json.dumps({"name": name, "args": args, "result": res}, ensure_ascii=False, indent=2) + "" + return "" + json.dumps( + {"name": name, "args": args, "result": str(res) if isinstance(res, Exception) else res}, + ensure_ascii=False, + indent=2, + ) + "" def _append_history(self, hist, tool_call, tool_res, reasoning_content=None): assistant_msg = { @@ -1604,6 +1616,8 @@ class LiteLLMBase(ABC): name = tc.function.name try: args = json_repair.loads(tc.function.arguments) + if not isinstance(args, dict): + raise TypeError(f"Tool arguments for {name} must be a JSON object, got {type(args).__name__}") if hasattr(self.toolcall_session, "tool_call_async"): result = await self.toolcall_session.tool_call_async(name, args) else: @@ -1720,6 +1734,8 @@ class LiteLLMBase(ABC): name = tc.function.name try: args = json_repair.loads(tc.function.arguments) + if not isinstance(args, dict): + raise TypeError(f"Tool arguments for {name} must be a JSON object, got {type(args).__name__}") if hasattr(self.toolcall_session, "tool_call_async"): result = await self.toolcall_session.tool_call_async(name, args) else: diff --git a/test/unit_test/agent/sandbox/test_local_provider.py b/test/unit_test/agent/sandbox/test_local_provider.py index e3bcd1486..25fbe7e03 100644 --- a/test/unit_test/agent/sandbox/test_local_provider.py +++ b/test/unit_test/agent/sandbox/test_local_provider.py @@ -3,12 +3,10 @@ import sys import pytest -from agent.sandbox.providers.base import SandboxProviderConfigError from agent.sandbox.providers.local import LocalProvider -def _make_provider(monkeypatch, tmp_path, **overrides): - monkeypatch.setenv("SANDBOX_LOCAL_ENABLED", "true") +def _make_provider(tmp_path, **overrides): config = { "python_bin": sys.executable, "work_dir": str(tmp_path), @@ -24,16 +22,14 @@ def _make_provider(monkeypatch, tmp_path, **overrides): return provider -def test_local_provider_requires_explicit_env_enable(monkeypatch, tmp_path): - monkeypatch.delenv("SANDBOX_LOCAL_ENABLED", raising=False) +def test_local_provider_initializes_from_config(tmp_path): provider = LocalProvider() - - with pytest.raises(SandboxProviderConfigError): - provider.initialize({"work_dir": str(tmp_path)}) + provider.initialize({"python_bin": sys.executable, "work_dir": str(tmp_path)}) + assert provider.health_check() is True -def test_local_provider_executes_python_main(monkeypatch, tmp_path): - provider = _make_provider(monkeypatch, tmp_path) +def test_local_provider_executes_python_main(tmp_path): + provider = _make_provider(tmp_path) instance = provider.create_instance("python") try: @@ -53,8 +49,8 @@ def test_local_provider_executes_python_main(monkeypatch, tmp_path): assert result.metadata["result_value"] == {"message": "hello ragflow"} -def test_local_provider_collects_artifacts(monkeypatch, tmp_path): - provider = _make_provider(monkeypatch, tmp_path) +def test_local_provider_collects_artifacts(tmp_path): + provider = _make_provider(tmp_path) instance = provider.create_instance("python") try: @@ -82,8 +78,8 @@ def test_local_provider_collects_artifacts(monkeypatch, tmp_path): ] -def test_local_provider_times_out(monkeypatch, tmp_path): - provider = _make_provider(monkeypatch, tmp_path, timeout=1) +def test_local_provider_times_out(tmp_path): + provider = _make_provider(tmp_path, timeout=1) instance = provider.create_instance("python") try: diff --git a/test/unit_test/agent/sandbox/test_sandbox_client.py b/test/unit_test/agent/sandbox/test_sandbox_client.py new file mode 100644 index 000000000..43e944ca6 --- /dev/null +++ b/test/unit_test/agent/sandbox/test_sandbox_client.py @@ -0,0 +1,43 @@ +import pytest +from agent.sandbox import client as sandbox_client +from agent.sandbox.providers.self_managed import SelfManagedProvider + +pytestmark = pytest.mark.p2 + + +def test_client_defaults_to_self_managed(monkeypatch): + class FakeSettingsService: + @staticmethod + def get_by_name(name): + return [] + + monkeypatch.setattr(sandbox_client, "SystemSettingsService", FakeSettingsService) + monkeypatch.setattr(SelfManagedProvider, "initialize", lambda self, config: True) + monkeypatch.setattr(sandbox_client, "_provider_manager", None) + + provider_manager = sandbox_client.get_provider_manager() + + assert provider_manager.get_provider_name() == "self_managed" + assert isinstance(provider_manager.get_provider(), SelfManagedProvider) + + +def test_self_managed_schema_uses_env_for_deployment_defaults(monkeypatch): + monkeypatch.setenv("SANDBOX_EXECUTOR_MANAGER_IMAGE", "custom-executor:latest") + monkeypatch.setenv("SANDBOX_EXECUTOR_MANAGER_POOL_SIZE", "7") + monkeypatch.setenv("SANDBOX_BASE_PYTHON_IMAGE", "custom-python:latest") + monkeypatch.setenv("SANDBOX_BASE_NODEJS_IMAGE", "custom-node:latest") + monkeypatch.setenv("SANDBOX_EXECUTOR_MANAGER_PORT", "19485") + monkeypatch.setenv("SANDBOX_ENABLE_SECCOMP", "true") + monkeypatch.setenv("SANDBOX_MAX_MEMORY", "512m") + monkeypatch.setenv("SANDBOX_TIMEOUT", "25s") + + schema = SelfManagedProvider.get_config_schema() + + assert schema["executor_manager_image"]["default"] == "custom-executor:latest" + assert schema["executor_manager_pool_size"]["default"] == 7 + assert schema["base_python_image"]["default"] == "custom-python:latest" + assert schema["base_nodejs_image"]["default"] == "custom-node:latest" + assert schema["executor_manager_port"]["default"] == 19485 + assert schema["enable_seccomp"]["default"] is True + assert schema["max_memory"]["default"] == "512m" + assert schema["sandbox_timeout"]["default"] == "25s" diff --git a/test/unit_test/agent/sandbox/test_ssh_provider.py b/test/unit_test/agent/sandbox/test_ssh_provider.py new file mode 100644 index 000000000..74787313d --- /dev/null +++ b/test/unit_test/agent/sandbox/test_ssh_provider.py @@ -0,0 +1,174 @@ +import base64 +from types import SimpleNamespace + +import pytest + +from agent.sandbox.providers.ssh import SSHProvider +from agent.sandbox.result_protocol import RESULT_MARKER_PREFIX + +pytestmark = pytest.mark.p3 + + +class _FakeWritableFile: + def __init__(self, sftp, path: str): + self._sftp = sftp + self._path = path + self._chunks: list[str] = [] + + def write(self, content: str): + self._chunks.append(content) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + self._sftp.files[self._path] = "".join(self._chunks).encode("utf-8") + return False + + +class _FakeReadableFile: + def __init__(self, payload: bytes): + self._payload = payload + + def read(self): + return self._payload + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + +class _FakeSFTP: + def __init__(self): + self.files: dict[str, bytes] = {} + self.closed = False + + def file(self, path: str, mode: str): + if "w" in mode: + return _FakeWritableFile(self, path) + return _FakeReadableFile(self.files[path]) + + def listdir_attr(self, path: str): + prefix = path.rstrip("/") + "/" + names = [] + for file_path, payload in self.files.items(): + if not file_path.startswith(prefix): + continue + relative = file_path[len(prefix):] + if "/" in relative: + continue + names.append( + SimpleNamespace( + filename=relative, + st_mode=0o100644, + st_size=len(payload), + ) + ) + return names + + def close(self): + self.closed = True + + +class _FakeClient: + def __init__(self, sftp: _FakeSFTP): + self._sftp = sftp + self.closed = False + + def open_sftp(self): + return self._sftp + + def close(self): + self.closed = True + + +def _build_provider(): + provider = SSHProvider() + provider.host = "example.com" + provider.port = 22 + provider.username = "ragflow" + provider.password = "secret" + provider.work_dir = "/tmp" + provider.command_template = "cd {workspace} && python3 {script_path}" + provider.timeout = 5 + provider.max_output_bytes = 1024 * 1024 + provider.max_artifacts = 20 + provider.max_artifact_bytes = 1024 * 1024 + provider._initialized = True + return provider + + +def test_ssh_provider_executes_python_main_and_collects_artifacts(monkeypatch): + provider = _build_provider() + fake_sftp = _FakeSFTP() + fake_client = _FakeClient(fake_sftp) + executed_commands: list[str] = [] + + monkeypatch.setattr(provider, "_create_ssh_client", lambda: fake_client) + monkeypatch.setattr(provider, "_create_remote_workspace", lambda client: "/tmp/ws-123") + + def _run_remote_command(client, command: str, timeout: int): + executed_commands.append(command) + if command.startswith("mkdir -p "): + return "", "", 0 + if command.startswith("cd /tmp/ws-123 && python3 /tmp/ws-123/main.py"): + fake_sftp.files["/tmp/ws-123/artifacts/chart.png"] = b"PNGDATA" + payload = base64.b64encode( + b'{"present":true,"value":{"message":"hello ssh"},"type":"json"}' + ).decode("ascii") + return f"debug line\n{RESULT_MARKER_PREFIX}{payload}\n", "", 0 + if command.startswith("rm -rf "): + return "", "", 0 + raise AssertionError(f"Unexpected command: {command}") + + monkeypatch.setattr(provider, "_run_remote_command", _run_remote_command) + + instance = provider.create_instance("python") + result = provider.execute_code( + instance.instance_id, + 'def main() -> dict:\n return {"message": "hello ssh"}\n', + "python", + timeout=5, + ) + provider.destroy_instance(instance.instance_id) + + assert result.exit_code == 0 + assert result.stdout == "debug line\n" + assert result.metadata["result_present"] is True + assert result.metadata["result_value"] == {"message": "hello ssh"} + assert result.metadata["artifacts"] == [ + { + "name": "chart.png", + "content_b64": base64.b64encode(b"PNGDATA").decode("ascii"), + "mime_type": "image/png", + "size": 7, + } + ] + assert "cd /tmp/ws-123 && python3 /tmp/ws-123/main.py" in executed_commands + assert fake_sftp.closed is True + assert fake_client.closed is True + + +def test_ssh_provider_propagates_timeouts(): + provider = _build_provider() + provider._instances["instance-1"] = { + "client": object(), + "sftp": _FakeSFTP(), + "remote_work_dir": "/tmp/ws-123", + "language": "python", + } + + def _timeout(*args, **kwargs): + raise TimeoutError("Execution timed out after 5 seconds") + + provider._run_remote_command = _timeout # type: ignore[method-assign] + + with pytest.raises(TimeoutError, match="Execution timed out"): + provider.execute_code( + "instance-1", + 'def main() -> dict:\n return {"ok": True}\n', + "python", + timeout=5, + ) diff --git a/uv.lock b/uv.lock index 63a118689..a9747f82b 100644 --- a/uv.lock +++ b/uv.lock @@ -795,6 +795,72 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/d4/a9/a58a63e2756e5d01901595af58c673f68de7621f28d71007479e00f45a6c/bce_python_sdk-0.9.67-py3-none-any.whl", hash = "sha256:3054879d098a92ceeb4b9ac1e64d2c658120a5a10e8e630f22410564b2170bf0" }, ] +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da" }, + { url = "https://mirrors.aliyun.com/pypi/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493" }, + { url = "https://mirrors.aliyun.com/pypi/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927" }, +] + [[package]] name = "beartype" version = "0.22.9" @@ -3203,6 +3269,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/99/15/4ac989bf4019271fa688d9f95c7b01088958306b4fdc6929f9fa042a6e81/inscriptis-2.7.1-py3-none-any.whl", hash = "sha256:fd41d122e92b646527bca413e9e0270793d42c11fbe8045e388686199b6f30ca" }, ] +[[package]] +name = "invoke" +version = "3.0.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/33/f6/227c48c5fe47fa178ccf1fda8f047d16c97ba926567b661e9ce2045c600c/invoke-3.0.3.tar.gz", hash = "sha256:437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/5a/de/bbc12563bbf979618d17625a4e753ff7a078523e28d870d3626daa97261a/invoke-3.0.3-py3-none-any.whl", hash = "sha256:f11327165e5cbb89b2ad1d88d3292b5113332c43b8553b494da435d6ec6f5053" }, +] + [[package]] name = "ir-datasets" version = "0.5.11" @@ -4607,6 +4682,21 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/00/2f/804f58f0b856ab3bf21617cccf5b39206e6c4c94c2cd227bde125ea6105f/parameterized-0.9.0-py2.py3-none-any.whl", hash = "sha256:4e0758e3d41bea3bbd05ec14fc2c24736723f243b28d702081aef438c9372b1b" }, ] +[[package]] +name = "paramiko" +version = "5.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "bcrypt" }, + { name = "cryptography" }, + { name = "invoke" }, + { name = "pynacl" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/62/93/dcc25d52f49022ae6175d15e6bd751f1acc99b98bc61fc55e5155a7be2e7/paramiko-5.0.0.tar.gz", hash = "sha256:36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/82/5b/eadf6d45de38d30ab603f49393b6cd2cbe7e233af8cf90197e32782b68a9/paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c" }, +] + [[package]] name = "patchright" version = "1.58.2" @@ -6135,6 +6225,7 @@ dependencies = [ { name = "opendal" }, { name = "opensearch-py" }, { name = "ormsgpack" }, + { name = "paramiko" }, { name = "pdfplumber" }, { name = "peewee" }, { name = "pluginlib" }, @@ -6280,6 +6371,7 @@ requires-dist = [ { name = "opendal", specifier = ">=0.45.0,<0.46.0" }, { name = "opensearch-py", specifier = "==2.7.1" }, { name = "ormsgpack", specifier = ">=1.5.0" }, + { name = "paramiko", specifier = ">=3.5.1" }, { name = "pdfplumber", specifier = "==0.10.4" }, { name = "peewee", specifier = ">=3.17.1,<4.0.0" }, { name = "pluginlib", specifier = ">=0.10.0" }, diff --git a/web/src/assets/admin-sandbox-settings.png b/web/src/assets/admin-sandbox-settings.png new file mode 100644 index 000000000..e0b885c9c Binary files /dev/null and b/web/src/assets/admin-sandbox-settings.png differ diff --git a/web/src/pages/admin/sandbox-settings.tsx b/web/src/pages/admin/sandbox-settings.tsx index 5c1083a4f..1b04f8e3e 100644 --- a/web/src/pages/admin/sandbox-settings.tsx +++ b/web/src/pages/admin/sandbox-settings.tsx @@ -1,14 +1,17 @@ -import { useEffect, useState } from 'react'; +import { FormEvent, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { + LucideChevronDown, LucideCloud, LucideLink, LucideLoader2, + LucideMonitor, LucideSave, LucideServer, + LucideTerminal, LucideZap, } from 'lucide-react'; @@ -20,6 +23,11 @@ import { CardHeader, CardTitle, } from '@/components/ui/card'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; import { Dialog, DialogContent, @@ -31,6 +39,7 @@ import { import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Textarea } from '@/components/ui/textarea'; import { @@ -48,7 +57,9 @@ import { ScrollArea } from '@/components/ui/scroll-area'; // Provider icons mapping const PROVIDER_ICONS: Record = { + local: LucideMonitor, self_managed: LucideServer, + ssh: LucideTerminal, aliyun_codeinterpreter: LucideCloud, e2b: LucideZap, }; @@ -60,6 +71,9 @@ function AdminSandboxSettings() { // State const [selectedProvider, setSelectedProvider] = useState(null); const [configValues, setConfigValues] = useState>({}); + const [sshAuthMode, setSshAuthMode] = useState<'password' | 'private_key'>( + 'password', + ); const [testModalOpen, setTestModalOpen] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; @@ -134,13 +148,25 @@ function AdminSandboxSettings() { } }, [currentConfig]); + useEffect(() => { + if (selectedProvider !== 'ssh') { + return; + } + const hasPrivateKey = Boolean( + String(configValues.private_key ?? '').trim(), + ); + setSshAuthMode(hasPrivateKey ? 'private_key' : 'password'); + }, [selectedProvider, configValues.private_key]); + // Apply schema defaults when provider schema changes useEffect(() => { if (providerSchema && Object.keys(providerSchema).length > 0) { setConfigValues((prev) => { const mergedConfig = { ...prev }; - // Apply schema defaults for any missing fields Object.entries(providerSchema).forEach(([fieldName, schema]) => { + if (schema.readonly) { + return; + } if ( mergedConfig[fieldName] === undefined && schema.default !== undefined @@ -156,8 +182,11 @@ function AdminSandboxSettings() { // Handle provider change const handleProviderChange = (providerId: string) => { setSelectedProvider(providerId); - // Force refetch config and schema from backend when switching providers - queryClient.invalidateQueries({ queryKey: ['admin/getSandboxConfig'] }); + if (currentConfig?.provider_type === providerId) { + setConfigValues(currentConfig.config || {}); + } else { + setConfigValues({}); + } queryClient.invalidateQueries({ queryKey: ['admin/getSandboxProviderSchema'], }); @@ -168,13 +197,50 @@ function AdminSandboxSettings() { setConfigValues((prev) => ({ ...prev, [fieldName]: value })); }; + const handleSshAuthModeChange = (mode: 'password' | 'private_key') => { + setSshAuthMode(mode); + setConfigValues((prev) => { + if (mode === 'password') { + return { + ...prev, + private_key: '', + passphrase: '', + }; + } + return { + ...prev, + password: '', + }; + }); + }; + + const buildSubmitConfig = () => { + if (selectedProvider !== 'ssh') { + return configValues; + } + + const nextConfig = { ...configValues }; + delete nextConfig.command_template; + + if (sshAuthMode === 'password') { + nextConfig.private_key = ''; + nextConfig.passphrase = ''; + } else { + nextConfig.password = ''; + } + + return nextConfig; + }; + // Handle save - const handleSave = () => { + const handleSave = (event?: FormEvent) => { + event?.preventDefault(); + if (!selectedProvider) return; setConfigMutation.mutate({ providerType: selectedProvider, - config: configValues, + config: buildSubmitConfig(), }); }; @@ -186,7 +252,7 @@ function AdminSandboxSettings() { setTestResult(null); testConnectionMutation.mutate({ providerType: selectedProvider, - config: configValues, + config: buildSubmitConfig(), }); }; @@ -199,6 +265,20 @@ function AdminSandboxSettings() { switch (schema.type) { case 'string': + if (schema.multiline) { + return ( +