Files
ragflow/agent/sandbox/providers/local.py
Magicbook1108 b28e134944 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)
2026-05-20 16:56:20 +08:00

353 lines
14 KiB
Python

#
# 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.
#
import base64
import json
import mimetypes
import os
import shutil
import signal
import subprocess
import time
import uuid
from pathlib import Path
from typing import 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
ALLOWED_ARTIFACT_EXTENSIONS = {
".csv",
".html",
".jpeg",
".jpg",
".json",
".pdf",
".png",
".svg",
}
LOCAL_PYTHON_THREAD_ENV_VARS = (
"OPENBLAS_NUM_THREADS",
"OMP_NUM_THREADS",
"MKL_NUM_THREADS",
"NUMEXPR_NUM_THREADS",
"BLIS_NUM_THREADS",
"VECLIB_MAXIMUM_THREADS",
)
class LocalProvider(SandboxProvider):
"""
Execute code as a local child process.
This provider is intentionally gated by SANDBOX_LOCAL_ENABLED because it is
not a sandbox boundary. Use a low-privilege runtime account.
"""
def __init__(self):
self.python_bin = "python3"
self.node_bin = "node"
self.work_dir = Path("/tmp/ragflow-codeexec")
self.timeout = 30
self.max_memory_mb = 512
self.max_output_bytes = 1024 * 1024
self.max_artifacts = 20
self.max_artifact_bytes = 10 * 1024 * 1024
self._initialized = False
self._instances: dict[str, Path] = {}
def initialize(self, config: Dict[str, Any]) -> bool:
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)
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)
instance_id = str(uuid.uuid4())
instance_dir = self.work_dir / instance_id
instance_dir.mkdir(mode=0o700)
(instance_dir / "artifacts").mkdir(mode=0o700)
self._instances[instance_id] = instance_dir
return SandboxInstance(
instance_id=instance_id,
provider="local",
status="running",
metadata={"language": language, "work_dir": str(instance_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.")
normalized_lang = self._normalize_language(language)
instance_dir = self._instances[instance_id]
args_json = json.dumps(arguments or {}, ensure_ascii=False)
command, script_path = self._prepare_script(instance_dir, normalized_lang, code, 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()
process = subprocess.Popen(
command,
cwd=instance_dir,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
env=self._build_child_env(instance_dir),
preexec_fn=self._limit_child_process if os.name == "posix" else None,
start_new_session=os.name == "posix",
)
try:
stdout, stderr = process.communicate(timeout=exec_timeout)
except subprocess.TimeoutExpired:
if os.name == "posix":
os.killpg(process.pid, signal.SIGKILL)
else:
process.kill()
process.communicate()
raise TimeoutError(f"Execution timed out after {exec_timeout} seconds")
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=process.returncode,
execution_time=execution_time,
metadata={
"instance_id": instance_id,
"language": normalized_lang,
"script_path": str(script_path),
"status": "ok" if process.returncode == 0 else "error",
"timeout": exec_timeout,
"artifacts": self._collect_artifacts(instance_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.")
instance_dir = self._instances.pop(instance_id)
shutil.rmtree(instance_dir)
return True
def health_check(self) -> bool:
return self._initialized and self.work_dir.exists() and os.access(self.work_dir, os.W_OK)
def get_supported_languages(self) -> List[str]:
return ["python", "javascript", "nodejs"]
@staticmethod
def get_config_schema() -> Dict[str, Dict]:
return {
"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:
if self.timeout <= 0:
raise SandboxProviderConfigError("SANDBOX_LOCAL_TIMEOUT must be greater than 0.")
if self.max_memory_mb <= 0:
raise SandboxProviderConfigError("SANDBOX_LOCAL_MAX_MEMORY_MB must be greater than 0.")
if self.max_output_bytes <= 0:
raise SandboxProviderConfigError("SANDBOX_LOCAL_MAX_OUTPUT_BYTES must be greater than 0.")
if self.max_artifacts < 0:
raise SandboxProviderConfigError("SANDBOX_LOCAL_MAX_ARTIFACTS must be greater than or equal to 0.")
if self.max_artifact_bytes <= 0:
raise SandboxProviderConfigError("SANDBOX_LOCAL_MAX_ARTIFACT_BYTES must be greater than 0.")
def _prepare_script(self, instance_dir: Path, language: str, code: str, args_json: str) -> tuple[list[str], Path]:
if language == "python":
script_path = instance_dir / "main.py"
script_path.write_text(build_python_wrapper(code, args_json), encoding="utf-8")
return [self.python_bin, str(script_path)], script_path
if language in {"javascript", "nodejs"}:
script_path = instance_dir / "main.js"
script_path.write_text(build_javascript_wrapper(code, args_json), encoding="utf-8")
return [self.node_bin, str(script_path)], script_path
raise RuntimeError(f"Unsupported language for local provider: {language}")
def _build_child_env(self, instance_dir: Path) -> dict[str, str]:
env = {
"HOME": str(instance_dir),
"MPLBACKEND": "Agg",
"PATH": os.environ.get("PATH", ""),
"PYTHONUNBUFFERED": "1",
"TMPDIR": str(instance_dir),
}
for name in LOCAL_PYTHON_THREAD_ENV_VARS:
value = os.environ.get(name)
if value is not None:
env[name] = value
return env
def _limit_child_process(self) -> None:
import resource
self._set_resource_limit(resource.RLIMIT_CPU, self.timeout + 1)
self._set_resource_limit(resource.RLIMIT_AS, self.max_memory_mb * 1024 * 1024)
self._set_resource_limit(resource.RLIMIT_FSIZE, self.max_artifact_bytes)
self._set_resource_limit(resource.RLIMIT_NOFILE, 64)
@staticmethod
def _set_resource_limit(kind: int, value: int) -> None:
import resource
_, hard = resource.getrlimit(kind)
limit = value if hard == resource.RLIM_INFINITY else min(value, hard)
resource.setrlimit(kind, (limit, limit))
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"Local execution output exceeded {self.max_output_bytes} bytes.")
def _collect_artifacts(self, artifacts_dir: Path) -> list[dict[str, Any]]:
artifacts: list[dict[str, Any]] = []
for path in sorted(artifacts_dir.rglob("*")):
if path.is_symlink():
raise RuntimeError(f"Artifact symlinks are not allowed: {path.name}")
if path.is_dir():
continue
if not path.is_file():
raise RuntimeError(f"Unsupported artifact entry: {path.name}")
if len(artifacts) >= self.max_artifacts:
raise RuntimeError(f"Local execution produced more than {self.max_artifacts} artifacts.")
size = path.stat().st_size
if size > self.max_artifact_bytes:
raise RuntimeError(f"Artifact exceeds {self.max_artifact_bytes} bytes: {path.name}")
ext = path.suffix.lower()
if ext not in ALLOWED_ARTIFACT_EXTENSIONS:
raise RuntimeError(f"Unsupported artifact type: {path.name}")
artifacts.append(
{
"name": path.relative_to(artifacts_dir).as_posix(),
"content_b64": base64.b64encode(path.read_bytes()).decode("ascii"),
"mime_type": mimetypes.guess_type(path.name)[0] or "application/octet-stream",
"size": size,
}
)
return artifacts
@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