# # 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