mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-28 19:58:11 +08:00
Feat: add local & ssh provider in admin panel (#15039)
### What problem does this PR solve? Feat: add local & ssh provider in admin panel ### Type of change - [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
664
agent/sandbox/providers/ssh.py
Normal file
664
agent/sandbox/providers/ssh.py
Normal file
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user