diff --git a/admin/server/services.py b/admin/server/services.py index a59c921c3b..c7bd352e37 100644 --- a/admin/server/services.py +++ b/admin/server/services.py @@ -504,6 +504,11 @@ class SandboxMgr: "description": "Tenki - Disposable microVM code sandboxes", "tags": ["saas", "cloud", "microvm", "isolated"], }, + "ucloud_agent_sandbox": { + "name": "UCloud Agent Sandbox", + "description": "UCloud Agent Sandbox - Disposable cloud sandboxes for agent code execution", + "tags": ["saas", "cloud", "isolated", "ucloud"], + }, } @staticmethod @@ -524,6 +529,7 @@ class SandboxMgr: AliyunCodeInterpreterProvider, E2BProvider, TenkiProvider, + UCloudAgentSandboxProvider, ) schemas = { @@ -533,6 +539,7 @@ class SandboxMgr: "aliyun_codeinterpreter": AliyunCodeInterpreterProvider.get_config_schema(), "e2b": E2BProvider.get_config_schema(), "tenki": TenkiProvider.get_config_schema(), + "ucloud_agent_sandbox": UCloudAgentSandboxProvider.get_config_schema(), } if provider_id not in schemas: @@ -599,6 +606,7 @@ class SandboxMgr: AliyunCodeInterpreterProvider, E2BProvider, TenkiProvider, + UCloudAgentSandboxProvider, ) try: @@ -644,6 +652,7 @@ class SandboxMgr: "aliyun_codeinterpreter": AliyunCodeInterpreterProvider, "e2b": E2BProvider, "tenki": TenkiProvider, + "ucloud_agent_sandbox": UCloudAgentSandboxProvider, } provider = provider_classes[provider_type]() is_valid, error_msg = provider.validate_config(config) @@ -692,6 +701,7 @@ class SandboxMgr: AliyunCodeInterpreterProvider, E2BProvider, TenkiProvider, + UCloudAgentSandboxProvider, ) # Instantiate provider based on type @@ -702,6 +712,7 @@ class SandboxMgr: "aliyun_codeinterpreter": AliyunCodeInterpreterProvider, "e2b": E2BProvider, "tenki": TenkiProvider, + "ucloud_agent_sandbox": UCloudAgentSandboxProvider, } if provider_type not in provider_classes: diff --git a/agent/sandbox/client.py b/agent/sandbox/client.py index cdcced4b0a..9c28048bfc 100644 --- a/agent/sandbox/client.py +++ b/agent/sandbox/client.py @@ -78,6 +78,7 @@ def _load_provider_from_settings() -> None: LocalProvider, SSHProvider, TenkiProvider, + UCloudAgentSandboxProvider, ) provider_classes = { @@ -87,6 +88,7 @@ def _load_provider_from_settings() -> None: "local": LocalProvider, "ssh": SSHProvider, "tenki": TenkiProvider, + "ucloud_agent_sandbox": UCloudAgentSandboxProvider, } if provider_type not in provider_classes: @@ -99,7 +101,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 in {"local", "ssh", "tenki"}: + if provider_type in {"local", "ssh", "tenki", "ucloud_agent_sandbox"}: raise SandboxProviderConfigError(message) logger.error(message) return diff --git a/agent/sandbox/providers/__init__.py b/agent/sandbox/providers/__init__.py index a1bb7c6c6e..e0935ea124 100644 --- a/agent/sandbox/providers/__init__.py +++ b/agent/sandbox/providers/__init__.py @@ -27,6 +27,7 @@ This package contains: - local.py: Local process provider implementation - ssh.py: Remote SSH provider implementation - tenki.py: Tenki disposable microVM provider implementation +- ucloud_agent_sandbox.py: UCloud Agent Sandbox provider implementation """ from .base import SandboxProvider, SandboxInstance, ExecutionResult, SandboxProviderConfigError @@ -37,6 +38,7 @@ from .e2b import E2BProvider from .local import LocalProvider from .ssh import SSHProvider from .tenki import TenkiProvider +from .ucloud_agent_sandbox import UCloudAgentSandboxProvider __all__ = [ "SandboxProvider", @@ -50,4 +52,5 @@ __all__ = [ "LocalProvider", "SSHProvider", "TenkiProvider", + "UCloudAgentSandboxProvider", ] diff --git a/agent/sandbox/providers/ucloud_agent_sandbox.py b/agent/sandbox/providers/ucloud_agent_sandbox.py new file mode 100644 index 0000000000..774c548764 --- /dev/null +++ b/agent/sandbox/providers/ucloud_agent_sandbox.py @@ -0,0 +1,471 @@ +# +# 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. +# + +"""UCloud Agent Sandbox provider for remote code execution.""" + +from __future__ import annotations + +import base64 +import json +import logging +import mimetypes +import os +import posixpath +import shlex +import time +import uuid +from typing import Any + +from agent.sandbox.result_protocol import build_javascript_wrapper, build_python_wrapper, extract_structured_result + +from .base import ExecutionResult, SandboxInstance, SandboxProvider, SandboxProviderConfigError + +logger = logging.getLogger(__name__) + +ALLOWED_ARTIFACT_EXTENSIONS = {".csv", ".html", ".jpeg", ".jpg", ".json", ".pdf", ".png", ".svg"} +DEFAULT_REGION = "cn-wlcb" +DOMAIN_SUFFIX = "sandbox.ucloudai.com" +SANDBOX_HOME = "/home/user" +MAX_ARTIFACT_DEPTH = 16 + + +class UCloudAgentSandboxProvider(SandboxProvider): + """Execute Python and JavaScript in disposable UCloud Agent Sandboxes.""" + + def __init__(self): + """Initialize the provider with safe defaults and no active instances.""" + self.api_key = "" + self.region = DEFAULT_REGION + self.domain = "" + self.api_url = "" + self.template = "base" + self.allow_internet_access = False + self.insecure_http = False + self.timeout = 30 + self.sandbox_timeout = 300 + 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: + """Validate and apply provider configuration. + + Args: + config: Provider credentials, endpoint options, and execution limits. + + Returns: + True when the provider is ready to create sandboxes. + + Raises: + SandboxProviderConfigError: If the configuration or SDK is invalid. + """ + self.api_key = str(config.get("api_key", "") or "").strip() + self.region = str(config.get("region", DEFAULT_REGION) or DEFAULT_REGION).strip() + self.domain = str(config.get("domain", "") or "").strip() + self.api_url = str(config.get("api_url", "") or "").strip() + self.template = str(config.get("template", "base") or "base").strip() + self.allow_internet_access = bool(config.get("allow_internet_access", False)) + self.insecure_http = bool(config.get("insecure_http", False)) + self.timeout = int(config.get("timeout", 30) or 30) + self.sandbox_timeout = int(config.get("sandbox_timeout", 300) or 300) + 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(config | {"api_key": self.api_key}) + if not is_valid: + raise SandboxProviderConfigError(error_message or "Invalid UCloud Agent Sandbox configuration.") + + _get_ucloud_sandbox_module() + self._initialized = True + logger.info("UCloud Agent Sandbox provider initialized") + return True + + def create_instance(self, template: str = "python") -> SandboxInstance: + """Create a disposable sandbox and its isolated execution workspace. + + Args: + template: Requested language identifier used to validate the runtime. + + Returns: + A RAGFlow sandbox instance handle. + """ + if not self._initialized: + raise RuntimeError("Provider not initialized. Call initialize() first.") + + language = self._normalize_language(template) + if language not in {"python", "nodejs"}: + raise RuntimeError(f"Unsupported language for UCloud Agent Sandbox provider: {template}") + + sdk = _get_ucloud_sandbox_module() + try: + sandbox = sdk.Sandbox.create( + template=self.template, + timeout=self.sandbox_timeout, + metadata={"source": "ragflow"}, + secure=True, + allow_internet_access=self.allow_internet_access, + **self._api_options(), + ) + except sdk.AuthenticationException as exc: + raise SandboxProviderConfigError("UCloud Agent Sandbox authentication failed: check the API key.") from exc + except sdk.RateLimitException as exc: + raise RuntimeError(f"UCloud Agent Sandbox rate limited, please retry: {exc}") from exc + except sdk.TimeoutException as exc: + raise TimeoutError("Timed out while creating a UCloud Agent Sandbox.") from exc + except Exception as exc: + raise RuntimeError(f"Failed to create UCloud Agent Sandbox: {exc}") from exc + + remote_work_dir = posixpath.join(SANDBOX_HOME, f"ragflow-codeexec-{uuid.uuid4().hex}") + try: + sandbox.commands.run( + f"mkdir -p {shlex.quote(posixpath.join(remote_work_dir, 'artifacts'))}", + timeout=min(self.timeout, 10), + request_timeout=self.timeout, + ) + except Exception: + self._safe_kill(sandbox) + raise + + instance_id = str(uuid.uuid4()) + self._instances[instance_id] = {"sandbox": sandbox, "remote_work_dir": remote_work_dir, "language": language} + return SandboxInstance( + instance_id=instance_id, + provider="ucloud_agent_sandbox", + status="running", + metadata={ + "language": language, + "remote_work_dir": remote_work_dir, + "sandbox_id": sandbox.sandbox_id, + "template": self.template, + }, + ) + + def execute_code( + self, + instance_id: str, + code: str, + language: str, + timeout: int = 10, + arguments: dict[str, Any] | None = None, + ) -> ExecutionResult: + """Execute wrapped code in an existing UCloud sandbox. + + Args: + instance_id: RAGFlow instance identifier returned by create_instance. + code: User-provided source code defining a main function. + language: Python or JavaScript language identifier. + timeout: Maximum execution duration in seconds. + arguments: Values passed to the user-defined main function. + + Returns: + Captured output, structured result metadata, and allowed artifacts. + """ + if not self._initialized: + raise RuntimeError("Provider not initialized. Call initialize() first.") + if instance_id not in self._instances: + raise RuntimeError(f"Unknown UCloud Agent Sandbox instance: {instance_id}") + + normalized_lang = self._normalize_language(language) + instance = self._instances[instance_id] + sandbox = instance["sandbox"] + remote_work_dir: str = instance["remote_work_dir"] + script_path, executable = self._prepare_script(sandbox, remote_work_dir, normalized_lang, code, arguments or {}) + + 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) + sdk = _get_ucloud_sandbox_module() + + start_time = time.time() + try: + sandbox.set_timeout(max(self.sandbox_timeout, exec_timeout + 30), request_timeout=self.timeout) + result = sandbox.commands.run( + f"{executable} {shlex.quote(script_path)}", + cwd=remote_work_dir, + timeout=exec_timeout, + request_timeout=max(self.timeout, exec_timeout), + ) + except sdk.CommandExitException as exc: + result = exc + except sdk.TimeoutException as exc: + raise TimeoutError(f"Execution timed out after {exec_timeout} seconds") from exc + except Exception as exc: + raise RuntimeError(f"UCloud Agent Sandbox execution failed: {exc}") from exc + execution_time = time.time() - start_time + + stdout = result.stdout or "" + stderr = result.stderr or "" + exit_code = int(result.exit_code) + 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, + "sandbox_id": sandbox.sandbox_id, + "language": normalized_lang, + "script_path": script_path, + "remote_work_dir": remote_work_dir, + "status": "ok" if exit_code == 0 else "error", + "timeout": exec_timeout, + "artifacts": self._collect_artifacts(sandbox, 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: + """Destroy a sandbox instance if it is still tracked by the provider.""" + if not self._initialized: + raise RuntimeError("Provider not initialized. Call initialize() first.") + instance = self._instances.pop(instance_id, None) + if instance is None: + return True + self._safe_kill(instance["sandbox"]) + return True + + def health_check(self) -> bool: + """Return whether the provider is initialized with an API key.""" + return self._initialized and bool(self.api_key) + + def get_supported_languages(self) -> list[str]: + """Return the language identifiers accepted by this provider.""" + return ["python", "javascript"] + + @staticmethod + def get_config_schema() -> dict[str, dict]: + """Return the Admin UI configuration schema for this provider.""" + return { + "api_key": { + "type": "string", + "required": True, + "label": "API Key", + "secret": True, + "description": "UCloud Agent Sandbox API key.", + }, + "region": { + "type": "string", + "required": False, + "label": "Region", + "default": DEFAULT_REGION, + "description": "UCloud Agent Sandbox region, for example cn-wlcb or us-ca.", + }, + "domain": { + "type": "string", + "required": False, + "label": "Domain", + "description": "Override the sandbox domain. Leave empty to derive it from Region.", + }, + "api_url": { + "type": "string", + "required": False, + "label": "API URL", + "description": "Override the UCloud Agent Sandbox control-plane API URL.", + }, + "template": { + "type": "string", + "required": False, + "label": "Template", + "default": "base", + "description": "Sandbox template. The base template includes Python and Node.js.", + }, + "allow_internet_access": { + "type": "boolean", + "required": False, + "label": "Allow Internet Access", + "default": False, + "description": "Allow sandboxed code to access the internet. Disabled by default.", + }, + "insecure_http": { + "type": "boolean", + "required": False, + "label": "Use Insecure HTTP", + "default": False, + "description": "Use HTTP instead of HTTPS. Enable only for trusted private deployments.", + }, + "timeout": { + "type": "integer", + "required": False, + "label": "Execution Timeout (seconds)", + "default": 30, + "min": 1, + "max": 600, + }, + "sandbox_timeout": { + "type": "integer", + "required": False, + "label": "Sandbox Lifetime (seconds)", + "default": 300, + "min": 60, + "max": 86400, + }, + "max_output_bytes": { + "type": "integer", + "required": False, + "label": "Max Output Bytes", + "default": 1048576, + "min": 1024, + "max": 10485760, + }, + "max_artifacts": { + "type": "integer", + "required": False, + "label": "Max Artifacts", + "default": 20, + "min": 0, + "max": 100, + }, + "max_artifact_bytes": { + "type": "integer", + "required": False, + "label": "Max Artifact Bytes", + "default": 10485760, + "min": 1024, + "max": 104857600, + }, + } + + def validate_config(self, config: dict[str, Any]) -> tuple[bool, str | None]: + """Validate required credentials and numeric execution limits.""" + if not str(config.get("api_key", "") or "").strip(): + return False, "UCloud Agent Sandbox API key is required" + if not str(config.get("template", "base") or "").strip(): + return False, "template is required" + for key in ("timeout", "sandbox_timeout", "max_output_bytes", "max_artifact_bytes"): + try: + value = int(config.get(key, self.get_config_schema()[key]["default"]) or 0) + except (TypeError, ValueError): + return False, f"{key} must be an integer" + if value <= 0: + return False, f"{key} must be greater than 0" + try: + max_artifacts = int(config.get("max_artifacts", 20) or 0) + except (TypeError, ValueError): + return False, "max_artifacts must be an integer" + if max_artifacts < 0: + return False, "max_artifacts must be greater than or equal to 0" + return True, None + + def _api_options(self) -> dict[str, Any]: + """Build keyword arguments shared by UCloud SDK API calls.""" + options: dict[str, Any] = { + "api_key": self.api_key, + "domain": self.domain or f"{self.region}.{DOMAIN_SUFFIX}", + "insecure_http": self.insecure_http, + "request_timeout": float(self.timeout), + "integration": "ragflow", + } + if self.api_url: + options["api_url"] = self.api_url + return options + + def _prepare_script(self, sandbox, remote_work_dir: str, language: str, code: str, arguments: dict[str, Any]) -> tuple[str, str]: + """Wrap user code, upload it, and return its path and executable.""" + args_json = json.dumps(arguments, ensure_ascii=False) + if language == "python": + script_name = "main.py" + script_content = build_python_wrapper(code, args_json) + executable = "python3" + elif language == "nodejs": + script_name = "main.js" + script_content = build_javascript_wrapper(code, args_json) + executable = "node" + else: + raise RuntimeError(f"Unsupported language for UCloud Agent Sandbox provider: {language}") + script_path = posixpath.join(remote_work_dir, script_name) + sandbox.files.write(script_path, script_content, request_timeout=self.timeout) + return script_path, executable + + def _validate_output_size(self, stdout: str, stderr: str) -> None: + """Reject combined standard output that exceeds the configured limit.""" + output_size = len(stdout.encode("utf-8")) + len(stderr.encode("utf-8")) + if output_size > self.max_output_bytes: + raise RuntimeError(f"UCloud Agent Sandbox execution output exceeded {self.max_output_bytes} bytes.") + + def _collect_artifacts(self, sandbox, artifacts_dir: str) -> list[dict[str, Any]]: + """Collect allowed files from the execution artifact directory.""" + artifacts: list[dict[str, Any]] = [] + self._collect_artifacts_recursive(sandbox, artifacts_dir, "", artifacts, depth=0) + return artifacts + + def _collect_artifacts_recursive(self, sandbox, current_dir: str, relative_dir: str, artifacts: list[dict[str, Any]], depth: int) -> None: + """Traverse artifact directories while enforcing type, size, and depth limits.""" + if depth > MAX_ARTIFACT_DEPTH: + raise RuntimeError(f"Artifact directory nesting exceeds {MAX_ARTIFACT_DEPTH} levels: {relative_dir}") + sdk = _get_ucloud_sandbox_module() + try: + entries = sandbox.files.list(current_dir, depth=1, request_timeout=self.timeout) + except sdk.FileNotFoundException: + return + for entry in sorted(entries, key=lambda item: item.path): + name = posixpath.basename(entry.path) + relative_path = posixpath.join(relative_dir, name) if relative_dir else name + if entry.symlink_target is not None: + raise RuntimeError(f"Artifact symlinks are not allowed: {relative_path}") + if entry.type == sdk.FileType.DIR: + self._collect_artifacts_recursive(sandbox, entry.path, relative_path, artifacts, depth + 1) + continue + if len(artifacts) >= self.max_artifacts: + raise RuntimeError(f"UCloud Agent Sandbox execution produced more than {self.max_artifacts} artifacts.") + if entry.size > self.max_artifact_bytes: + raise RuntimeError(f"Artifact exceeds {self.max_artifact_bytes} bytes: {relative_path}") + extension = os.path.splitext(name)[1].lower() + if extension not in ALLOWED_ARTIFACT_EXTENSIONS: + raise RuntimeError(f"Unsupported artifact type: {relative_path}") + content = bytes(sandbox.files.read(entry.path, format="bytes", request_timeout=self.timeout)) + artifacts.append( + { + "name": relative_path, + "content_b64": base64.b64encode(content).decode("ascii"), + "mime_type": mimetypes.guess_type(name)[0] or "application/octet-stream", + "size": entry.size, + } + ) + + def _safe_kill(self, sandbox) -> None: + """Best-effort terminate a remote sandbox during cleanup.""" + try: + sandbox.kill(request_timeout=self.timeout) + except Exception as exc: # noqa: BLE001 - cleanup is deliberately best-effort + logger.warning("Failed to kill UCloud Agent Sandbox %s: %s", sandbox.sandbox_id, exc) + + @staticmethod + def _normalize_language(language: str) -> str: + """Normalize supported language aliases to provider runtime names.""" + value = (language or "python").lower() + if value in {"python", "python3"}: + return "python" + if value in {"javascript", "js", "node", "nodejs"}: + return "nodejs" + return value + + +def _get_ucloud_sandbox_module(): + """Import and return the UCloud SDK with a provider-specific error.""" + try: + import ucloud_sandbox + except ImportError as exc: + raise SandboxProviderConfigError("ucloud-sandbox is required for the UCloud Agent Sandbox provider.") from exc + return ucloud_sandbox diff --git a/conf/system_settings.json b/conf/system_settings.json index f123e4508d..78d154b0bd 100644 --- a/conf/system_settings.json +++ b/conf/system_settings.json @@ -83,6 +83,12 @@ "source": "variable", "data_type": "json", "value": "{}" + }, + { + "name": "sandbox.ucloud_agent_sandbox", + "source": "variable", + "data_type": "json", + "value": "{}" } ] } diff --git a/docs/administrator/admin/admin_ui/configure_code_execution_sandbox.md b/docs/administrator/admin/admin_ui/configure_code_execution_sandbox.md index be0c3514f6..2ab1394f57 100644 --- a/docs/administrator/admin/admin_ui/configure_code_execution_sandbox.md +++ b/docs/administrator/admin/admin_ui/configure_code_execution_sandbox.md @@ -19,6 +19,7 @@ The current page supports the following `Provider` options: | `SSH` | Execute code on a remote machine through SSH. | | `AliyunCodeInterpreter` | Use Alibaba Cloud Function Compute Code Interpreter. | | `E2B` | Use E2B Cloud Code Execution Sandboxes. | +| `UCloud Agent Sandbox` | Run code in disposable UCloud cloud sandboxes. | After selecting a `Provider`, the page displays the corresponding configuration area. @@ -33,6 +34,7 @@ After selecting a `Provider`, the page displays the corresponding configuration | `SSH` | Existing independent execution servers | Executes code on a remote host through SSH. | Suitable for reusing existing servers or custom runtime environments. | | `AliyunCodeInterpreter` | Alibaba Cloud-related services | Uses a cloud service to provide the code execution environment, making elastic scaling easier. | Suitable for organizations already using the corresponding cloud service. | | `E2B` | Fast access to a cloud sandbox | Executes code in an isolated environment provided by E2B. | Suitable for scenarios that need quick use without deployment. | +| `UCloud Agent Sandbox` | UCloud-hosted agent workloads | Creates a disposable sandbox from a template containing Python and Node.js. | Suitable for teams using UCloud or needing managed sandboxes in China or North America. | ## Test and Save Configuration @@ -152,3 +154,29 @@ Alibaba Cloud `CodeInterpreter` configuration connects to Alibaba Cloud CodeInte | `API Key` | API key provided by E2B Cloud for authentication. | Log in to the E2B platform, create the corresponding API key, and enter it. | | `Region` | Region where the E2B service is located. | Enter the actual region, such as `us`. | | `Request Timeout (seconds)` | Timeout for requests to the E2B service. | The default is 30 seconds. Increase it if the network is slow or execution takes longer. | + +## UCloud Agent Sandbox Configuration + +`UCloud Agent Sandbox` creates a fresh cloud sandbox for each code execution and destroys it when execution finishes. RAGFlow uses UCloud's native SDK and supports Python and JavaScript with the built-in `base` template. + +1. Obtain an API key from [UCloud ModelVerse API Keys](https://astraflow.ucloud.cn/modelverse/api-keys). +2. Go to **UCloud Agent Sandbox Configuration**. +3. Enter the API key and select the desired region. +4. Click **Test connection**, then click **Save** after the test succeeds. + +| Parameter | Description | Recommendation | +| --- | --- | --- | +| `API Key` | UCloud Agent Sandbox API key used for authentication. | Keep it secret and rotate it according to your organization's credential policy. | +| `Region` | Sandbox region. Supported values include `cn-wlcb` and `us-ca`. | Keep `cn-wlcb` unless workloads should run in North America. | +| `Domain` | Optional sandbox domain override. | Leave empty to derive `.sandbox.ucloudai.com` from `Region`. | +| `API URL` | Optional control-plane endpoint override. | Leave empty for the public service endpoint. | +| `Template` | UCloud sandbox template. | Use `base`, which includes both Python and Node.js. | +| `Allow Internet Access` | Allows executed code to make outbound network requests. | Disabled by default. Enable only when the code needs external services or package downloads. | +| `Use Insecure HTTP` | Uses HTTP rather than HTTPS. | Keep disabled except in a trusted private test deployment. | +| `Execution Timeout` | Maximum duration of one code execution. | The default is 30 seconds. | +| `Sandbox Lifetime` | Maximum lifetime of the disposable sandbox. | The default is 300 seconds and is extended when needed for an allowed execution. | +| Output and artifact limits | Bound console output and files returned from `artifacts/`. | Keep the defaults unless the workload has a known need for larger results. | + +Files written under the execution workspace's `artifacts/` directory are returned to RAGFlow. Supported artifact extensions are `.csv`, `.html`, `.jpeg`, `.jpg`, `.json`, `.pdf`, `.png`, and `.svg`. + +See the [UCloud Agent Sandbox prerequisites](https://astraflow.ucloud.cn/docs/agent-sandbox/product/prerequisites) and [region documentation](https://astraflow.ucloud.cn/docs/agent-sandbox/product/region) for service-side details. diff --git a/docs/guides/agent/agent_quickstarts/sandbox_quickstart.md b/docs/guides/agent/agent_quickstarts/sandbox_quickstart.md index d286cd0b61..6258989ed0 100644 --- a/docs/guides/agent/agent_quickstarts/sandbox_quickstart.md +++ b/docs/guides/agent/agent_quickstarts/sandbox_quickstart.md @@ -26,7 +26,7 @@ Configure sandbox providers from the admin page: - `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`, `e2b`, and `tenki`: Cloud providers. +- `aliyun_codeinterpreter`, `e2b`, `tenki`, and `ucloud_agent_sandbox`: Cloud providers. ## Provider Options @@ -40,6 +40,7 @@ Admin > Sandbox Settings after the services are up. - `ssh`: Runs code on a remote machine over SSH. - `aliyun_codeinterpreter` and `e2b`: Cloud-hosted providers that remain available in the admin provider list. - `tenki`: Cloud-hosted provider that runs each execution in a disposable [Tenki](https://tenki.cloud) microVM. See [Tenki](#tenki) below. +- `ucloud_agent_sandbox`: Cloud-hosted provider that runs each execution in a disposable [UCloud Agent Sandbox](https://astraflow.ucloud.cn/docs/agent-sandbox). See [UCloud Agent Sandbox](#ucloud-agent-sandbox) below. ### Tenki @@ -65,6 +66,28 @@ Notes: - Files written to the `artifacts/` directory of the working directory are returned as run artifacts. - The provider uses only Tenki's create/exec/destroy operations; it does not use volumes or snapshots. +### UCloud Agent Sandbox + +`ucloud_agent_sandbox` uses UCloud's native Sandbox SDK to create one disposable sandbox for each CodeExec run. No local sandbox service or Docker runtime is required. + +Configure it in **Admin > Sandbox Settings**: + +- `api_key` (required): obtain one from [UCloud ModelVerse API Keys](https://astraflow.ucloud.cn/modelverse/api-keys). +- `region`: defaults to `cn-wlcb`; `us-ca` is also supported. +- `domain` and `api_url`: optional endpoint overrides for private or custom deployments. +- `template`: defaults to `base`, which includes Python and Node.js runtimes. +- `allow_internet_access`: defaults to `false`; enable it only when sandboxed code needs outbound network access. +- `timeout`, `sandbox_timeout`, and the output/artifact limits can be tuned for the workload. + +Notes: + +- Supported languages are Python and JavaScript. +- RAGFlow uses HTTPS with TLS certificate verification. The `insecure_http` option is intended only for trusted test deployments. +- Files written to the execution workspace's `artifacts/` directory are returned as run artifacts. +- The Python runtime dependency is installed with RAGFlow. The Go runtime uses UCloud's native Go SDK. + +See [UCloud Agent Sandbox prerequisites](https://astraflow.ucloud.cn/docs/agent-sandbox/product/prerequisites) and [regions](https://astraflow.ucloud.cn/docs/agent-sandbox/product/region). + ## Prerequisites - Linux distribution compatible with gVisor. diff --git a/go.mod b/go.mod index ac7bcf1e06..795e1bf498 100644 --- a/go.mod +++ b/go.mod @@ -51,6 +51,7 @@ require ( github.com/signintech/gopdf v0.36.1 github.com/siongui/gojianfan v0.0.0-20210926212422-2f175ac615de github.com/spf13/viper v1.18.2 + github.com/ucloud/ucloud-sandbox-sdk-go v0.0.0-20260807065450-08464aef9ed5 github.com/xuri/excelize/v2 v2.11.0 github.com/yfedoseev/office_oxide/go v0.1.8 github.com/yfedoseev/pdf_oxide/go v0.3.67 diff --git a/go.sum b/go.sum index 15246d8c7b..355378b132 100644 --- a/go.sum +++ b/go.sum @@ -510,6 +510,8 @@ github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ucloud/ucloud-sandbox-sdk-go v0.0.0-20260807065450-08464aef9ed5 h1:okVowRVpknqiBSurlOrG3YIqp5j4VxXtdS938XVAiWY= +github.com/ucloud/ucloud-sandbox-sdk-go v0.0.0-20260807065450-08464aef9ed5/go.mod h1:097WABFNud50hH2KImyZ8WOJeb0lM2A4+gLvQ1kllsc= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= diff --git a/internal/agent/sandbox/manager.go b/internal/agent/sandbox/manager.go index dbdb8859c5..86adf2f6bd 100644 --- a/internal/agent/sandbox/manager.go +++ b/internal/agent/sandbox/manager.go @@ -24,7 +24,7 @@ // Go port reads this via `internal/dao.SystemSettingsDAO`. // 2. SANDBOX_PROVIDER_TYPE env var — defaults to "self_managed". // 3. SANDBOX_EXECUTOR_MANAGER_URL / AGENTRUN_* / LOCAL_* / SSH_* -// / E2B_* env vars for the per-provider knobs. The +// / E2B_* / UCLOUD_SANDBOX_* env vars for the per-provider knobs. The // `xxxConfigFromEnv` helpers in each provider file build the // same config map the admin-panel JSON would, so the // `FromConfig` constructor is the single source of truth. @@ -301,8 +301,10 @@ func buildProvider(t ProviderType) (SandboxProvider, error) { return newSSHProviderFromEnv(), nil case ProviderTenki: return newTenkiProviderFromEnv(), nil + case ProviderUCloudAgentSandbox: + return newUCloudAgentSandboxProviderFromEnv(), nil default: - return nil, fmt.Errorf("unknown provider type %q (known: self_managed, aliyun_codeinterpreter, e2b, local, ssh, tenki)", t) + return nil, fmt.Errorf("unknown provider type %q (known: self_managed, aliyun_codeinterpreter, e2b, local, ssh, tenki, ucloud_agent_sandbox)", t) } } @@ -324,7 +326,9 @@ func buildProviderFromConfig(t ProviderType, cfg map[string]any) (SandboxProvide return newSSHProviderFromConfig(cfg), nil case ProviderTenki: return newTenkiProviderFromConfig(cfg), nil + case ProviderUCloudAgentSandbox: + return newUCloudAgentSandboxProviderFromConfig(cfg), nil default: - return nil, fmt.Errorf("unknown provider type %q (known: self_managed, aliyun_codeinterpreter, e2b, local, ssh, tenki)", t) + return nil, fmt.Errorf("unknown provider type %q (known: self_managed, aliyun_codeinterpreter, e2b, local, ssh, tenki, ucloud_agent_sandbox)", t) } } diff --git a/internal/agent/sandbox/manager_test.go b/internal/agent/sandbox/manager_test.go index 2f4200a9e6..0910c955e9 100644 --- a/internal/agent/sandbox/manager_test.go +++ b/internal/agent/sandbox/manager_test.go @@ -80,7 +80,7 @@ func (s *stubProvider) SupportedLanguages() []string func TestProviderManager_BuildProvider_KnownTypes(t *testing.T) { t.Parallel() - for _, ptype := range []ProviderType{ProviderSelfManaged, ProviderAliyun, ProviderE2B, ProviderLocal, ProviderSSH} { + for _, ptype := range []ProviderType{ProviderSelfManaged, ProviderAliyun, ProviderE2B, ProviderLocal, ProviderSSH, ProviderUCloudAgentSandbox} { t.Run(string(ptype), func(t *testing.T) { p, err := buildProvider(ptype) if err != nil { diff --git a/internal/agent/sandbox/provider.go b/internal/agent/sandbox/provider.go index e14204bd1a..e8d275f4dc 100644 --- a/internal/agent/sandbox/provider.go +++ b/internal/agent/sandbox/provider.go @@ -69,6 +69,10 @@ const ( // ProviderTenki runs each execution in a disposable Tenki // microVM (TenkiCloud Go SDK). Matches Python's TenkiProvider. ProviderTenki ProviderType = "tenki" + + // ProviderUCloudAgentSandbox runs each execution in a disposable + // UCloud Agent Sandbox using UCloud's native Go SDK. + ProviderUCloudAgentSandbox ProviderType = "ucloud_agent_sandbox" ) // ErrE2BProviderNotImplemented is returned when an operator configures diff --git a/internal/agent/sandbox/ucloud_agent_sandbox.go b/internal/agent/sandbox/ucloud_agent_sandbox.go new file mode 100644 index 0000000000..1f593ea119 --- /dev/null +++ b/internal/agent/sandbox/ucloud_agent_sandbox.go @@ -0,0 +1,400 @@ +// +// 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. +// + +package sandbox + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "mime" + "path" + "ragflow/internal/common" + "sort" + "strings" + "sync" + "time" + + "github.com/google/uuid" + ucloudsdk "github.com/ucloud/ucloud-sandbox-sdk-go" +) + +const ( + ucloudAgentSandboxDefaultRegion = "cn-wlcb" + ucloudAgentSandboxDomainSuffix = "sandbox.ucloudai.com" + ucloudAgentSandboxDefaultTemplate = "base" + ucloudAgentSandboxHome = "/home/user" + ucloudAgentSandboxMaxArtifactDepth = 16 + ucloudAgentSandboxDefaultTimeout = 30 + ucloudAgentSandboxDefaultLifetime = 300 + ucloudAgentSandboxDefaultMaxOutputBytes = 1024 * 1024 + ucloudAgentSandboxDefaultMaxArtifacts = 20 + ucloudAgentSandboxDefaultMaxArtifactSize = 10 * 1024 * 1024 +) + +type ucloudAgentSandboxInstance struct { + sandbox *ucloudsdk.Sandbox + remoteWorkDir string +} + +// UCloudAgentSandboxProvider executes code in disposable UCloud Agent Sandboxes. +type UCloudAgentSandboxProvider struct { + client *ucloudsdk.Client + apiKey string + region string + domain string + apiURL string + template string + allowInternetAccess bool + insecureHTTP bool + timeoutSec int + sandboxTimeoutSec int + maxOutputBytes int + maxArtifacts int + maxArtifactBytes int + + mu sync.RWMutex + initialized bool + instances map[string]*ucloudAgentSandboxInstance +} + +func newUCloudAgentSandboxProviderFromEnv() *UCloudAgentSandboxProvider { + return newUCloudAgentSandboxProviderFromConfig(ucloudAgentSandboxConfigFromEnv()) +} + +func ucloudAgentSandboxConfigFromEnv() map[string]any { + return map[string]any{ + "api_key": common.GetEnv(common.EnvUCloudSandboxAPIKey), + "region": common.GetEnv(common.EnvUCloudSandboxRegion), + "domain": common.GetEnv(common.EnvUCloudSandboxDomain), + "api_url": common.GetEnv(common.EnvUCloudSandboxAPIURL), + "template": common.GetEnv(common.EnvUCloudSandboxTemplate), + "allow_internet_access": common.GetEnv(common.EnvUCloudSandboxAllowInternetAccess), + "insecure_http": common.GetEnv(common.EnvUCloudSandboxInsecureHTTP), + "timeout": common.GetEnv(common.EnvUCloudSandboxExecutionTimeout), + "sandbox_timeout": common.GetEnv(common.EnvUCloudSandboxTimeout), + "max_output_bytes": common.GetEnv(common.EnvUCloudSandboxMaxOutputBytes), + "max_artifacts": common.GetEnv(common.EnvUCloudSandboxMaxArtifacts), + "max_artifact_bytes": common.GetEnv(common.EnvUCloudSandboxMaxArtifactBytes), + } +} + +func newUCloudAgentSandboxProviderFromConfig(cfg map[string]any) *UCloudAgentSandboxProvider { + region := strings.TrimSpace(configString(cfg, "region")) + if region == "" { + region = ucloudAgentSandboxDefaultRegion + } + template := strings.TrimSpace(configString(cfg, "template")) + if template == "" { + template = ucloudAgentSandboxDefaultTemplate + } + return &UCloudAgentSandboxProvider{ + apiKey: strings.TrimSpace(configString(cfg, "api_key")), + region: region, + domain: strings.TrimSpace(configString(cfg, "domain")), + apiURL: strings.TrimSpace(configString(cfg, "api_url")), + template: template, + allowInternetAccess: strings.EqualFold(configString(cfg, "allow_internet_access"), "true"), + insecureHTTP: strings.EqualFold(configString(cfg, "insecure_http"), "true"), + timeoutSec: configInt(cfg, "timeout", ucloudAgentSandboxDefaultTimeout), + sandboxTimeoutSec: configInt(cfg, "sandbox_timeout", ucloudAgentSandboxDefaultLifetime), + maxOutputBytes: configInt(cfg, "max_output_bytes", ucloudAgentSandboxDefaultMaxOutputBytes), + maxArtifacts: configInt(cfg, "max_artifacts", ucloudAgentSandboxDefaultMaxArtifacts), + maxArtifactBytes: configInt(cfg, "max_artifact_bytes", ucloudAgentSandboxDefaultMaxArtifactSize), + instances: make(map[string]*ucloudAgentSandboxInstance), + } +} + +// ProviderType returns ProviderUCloudAgentSandbox. +func (p *UCloudAgentSandboxProvider) ProviderType() ProviderType { + return ProviderUCloudAgentSandbox +} + +// Initialize creates the UCloud SDK client. Connectivity is verified by the +// admin connection test, which creates and executes in a temporary sandbox. +func (p *UCloudAgentSandboxProvider) Initialize(context.Context) error { + if p.apiKey == "" { + return errors.New("ucloud agent sandbox: API key is required") + } + if p.timeoutSec <= 0 || p.sandboxTimeoutSec <= 0 || p.maxOutputBytes <= 0 || p.maxArtifactBytes <= 0 || p.maxArtifacts < 0 { + return errors.New("ucloud agent sandbox: timeout and size limits must be positive, and max_artifacts must be non-negative") + } + domain := p.domain + if domain == "" { + domain = p.region + "." + ucloudAgentSandboxDomainSuffix + } + options := []ucloudsdk.ClientOption{ + ucloudsdk.WithRequestTimeout(time.Duration(p.timeoutSec) * time.Second), + ucloudsdk.WithInsecureHTTP(p.insecureHTTP), + // The SDK currently defaults to skipping TLS verification. RAGFlow + // always opts into certificate verification. + ucloudsdk.WithInsecureSkipTLS(false), + } + if p.apiURL != "" { + options = append(options, ucloudsdk.WithAPIURL(p.apiURL)) + } + p.client = ucloudsdk.NewClient(domain, p.apiKey, options...) + p.mu.Lock() + p.initialized = true + p.mu.Unlock() + return nil +} + +// SupportedLanguages returns the runtimes included in UCloud's base template. +func (p *UCloudAgentSandboxProvider) SupportedLanguages() []string { + return []string{"python", "javascript"} +} + +// CreateInstance provisions a fresh UCloud Agent Sandbox. +func (p *UCloudAgentSandboxProvider) CreateInstance(ctx context.Context, template string) (*SandboxInstance, error) { + if !p.isInitialized() { + return nil, errors.New("ucloud agent sandbox: provider not initialized") + } + language := normalizeLanguage(template) + if language == "" { + return nil, fmt.Errorf("ucloud agent sandbox: unsupported language %q", template) + } + sbx, err := p.client.CreateSandbox(ctx, + ucloudsdk.WithTemplate(p.template), + ucloudsdk.WithTimeout(p.sandboxTimeoutSec), + ucloudsdk.WithMetadata(map[string]string{"source": "ragflow"}), + ucloudsdk.WithManageBy("ragflow"), + ucloudsdk.WithSecure(true), + ucloudsdk.WithAllowInternetAccess(p.allowInternetAccess), + ) + if err != nil { + return nil, fmt.Errorf("ucloud agent sandbox: create sandbox: %w", err) + } + remoteWorkDir := path.Join(ucloudAgentSandboxHome, "ragflow-codeexec-"+uuid.NewString()) + if _, err := sbx.Commands.Run(ctx, "mkdir -p "+shq(path.Join(remoteWorkDir, "artifacts")), ucloudsdk.WithCommandTimeout(minTimeout(p.timeoutSec, 10))); err != nil { + _, _ = sbx.Kill(ctx) + return nil, fmt.Errorf("ucloud agent sandbox: create workspace: %w", err) + } + p.mu.Lock() + p.instances[sbx.ID] = &ucloudAgentSandboxInstance{sandbox: sbx, remoteWorkDir: remoteWorkDir} + p.mu.Unlock() + return &SandboxInstance{ + InstanceID: sbx.ID, + Provider: ProviderUCloudAgentSandbox, + Status: "running", + Metadata: map[string]any{ + "language": language, + "remote_work_dir": remoteWorkDir, + "sandbox_id": sbx.ID, + "template": p.template, + }, + }, nil +} + +// ExecuteCode writes the shared RAGFlow wrapper into the sandbox and runs it. +func (p *UCloudAgentSandboxProvider) ExecuteCode( + ctx context.Context, + inst *SandboxInstance, + code, language string, + timeoutSec int, + args map[string]any, +) (*ExecutionResult, error) { + if !p.isInitialized() { + return nil, errors.New("ucloud agent sandbox: provider not initialized") + } + if inst == nil || inst.InstanceID == "" { + return nil, errors.New("ucloud agent sandbox: instance id required") + } + p.mu.RLock() + instance := p.instances[inst.InstanceID] + p.mu.RUnlock() + if instance == nil { + return nil, fmt.Errorf("ucloud agent sandbox: unknown instance %q", inst.InstanceID) + } + normalizedLanguage := normalizeLanguage(language) + if normalizedLanguage == "" { + return nil, fmt.Errorf("ucloud agent sandbox: unsupported language %q", language) + } + executionTimeout, err := validateTimeout(timeoutSec) + if err != nil { + return nil, err + } + if executionTimeout == 0 || executionTimeout > p.timeoutSec { + executionTimeout = p.timeoutSec + } + argsJSON, err := argsToJSON(args) + if err != nil { + return nil, err + } + + var scriptName, scriptContent, executable string + if normalizedLanguage == "python" { + scriptName = "main.py" + scriptContent = BuildPythonWrapper(code, argsJSON) + executable = "python3" + } else { + scriptName = "main.js" + scriptContent = BuildJavaScriptWrapper(code, argsJSON) + executable = "node" + } + scriptPath := path.Join(instance.remoteWorkDir, scriptName) + if _, err := instance.sandbox.Files.Write(ctx, scriptPath, scriptContent); err != nil { + return nil, fmt.Errorf("ucloud agent sandbox: write script: %w", err) + } + if err := instance.sandbox.SetTimeout(ctx, max(p.sandboxTimeoutSec, executionTimeout+30)); err != nil { + return nil, fmt.Errorf("ucloud agent sandbox: extend sandbox timeout: %w", err) + } + + started := time.Now() + commandResult, runErr := instance.sandbox.Commands.Run( + ctx, + executable+" "+shq(scriptPath), + ucloudsdk.WithCwd(instance.remoteWorkDir), + ucloudsdk.WithCommandTimeout(executionTimeout), + ) + executionTime := time.Since(started).Seconds() + if runErr != nil { + var exitErr *ucloudsdk.CommandExitError + if errors.As(runErr, &exitErr) { + commandResult = &ucloudsdk.CommandResult{Stdout: exitErr.Stdout, Stderr: exitErr.Stderr, ExitCode: exitErr.ExitCode, Error: exitErr.Message} + } else { + var timeoutErr *ucloudsdk.TimeoutError + if errors.As(runErr, &timeoutErr) || errors.Is(runErr, context.DeadlineExceeded) { + return nil, fmt.Errorf("ucloud agent sandbox: execution timed out after %d seconds: %w", executionTimeout, runErr) + } + return nil, fmt.Errorf("ucloud agent sandbox: execute code: %w", runErr) + } + } + if commandResult == nil { + return nil, errors.New("ucloud agent sandbox: command returned no result") + } + if len(commandResult.Stdout)+len(commandResult.Stderr) > p.maxOutputBytes { + return nil, fmt.Errorf("ucloud agent sandbox: execution output exceeded %d bytes", p.maxOutputBytes) + } + stdout, structured := ExtractStructuredResult(commandResult.Stdout) + artifacts := make([]map[string]any, 0) + if err := p.collectArtifacts(ctx, instance.sandbox, path.Join(instance.remoteWorkDir, "artifacts"), "", &artifacts, 0); err != nil { + return nil, err + } + return &ExecutionResult{ + Stdout: stdout, + Stderr: commandResult.Stderr, + ExitCode: commandResult.ExitCode, + ExecutionTime: executionTime, + Metadata: map[string]any{ + "instance_id": inst.InstanceID, + "sandbox_id": instance.sandbox.ID, + "language": normalizedLanguage, + "script_path": scriptPath, + "remote_work_dir": instance.remoteWorkDir, + "status": map[bool]string{true: "ok", false: "error"}[commandResult.ExitCode == 0], + "timeout": executionTimeout, + "artifacts": artifacts, + "result_present": structured["present"], + "result_value": structured["value"], + "result_type": structured["type"], + }, + }, nil +} + +// DestroyInstance terminates the sandbox. A missing instance is already clean. +func (p *UCloudAgentSandboxProvider) DestroyInstance(ctx context.Context, inst *SandboxInstance) error { + if !p.isInitialized() { + return errors.New("ucloud agent sandbox: provider not initialized") + } + if inst == nil || inst.InstanceID == "" { + return errors.New("ucloud agent sandbox: instance id required") + } + p.mu.Lock() + instance := p.instances[inst.InstanceID] + delete(p.instances, inst.InstanceID) + p.mu.Unlock() + if instance == nil { + return nil + } + if _, err := instance.sandbox.Kill(ctx); err != nil { + return fmt.Errorf("ucloud agent sandbox: kill sandbox: %w", err) + } + return nil +} + +// HealthCheck reports whether the provider has a configured client. +func (p *UCloudAgentSandboxProvider) HealthCheck(context.Context) error { + if !p.isInitialized() || p.client == nil { + return errors.New("ucloud agent sandbox: provider not initialized") + } + return nil +} + +func (p *UCloudAgentSandboxProvider) collectArtifacts( + ctx context.Context, + sbx *ucloudsdk.Sandbox, + currentDir, relativeDir string, + artifacts *[]map[string]any, + depth int, +) error { + if depth > ucloudAgentSandboxMaxArtifactDepth { + return fmt.Errorf("ucloud agent sandbox: artifact directory nesting exceeds %d levels: %s", ucloudAgentSandboxMaxArtifactDepth, relativeDir) + } + entries, err := sbx.Files.List(ctx, currentDir, ucloudsdk.WithDepth(1)) + if err != nil { + if errors.Is(err, ucloudsdk.ErrNotFound) { + return nil + } + return fmt.Errorf("ucloud agent sandbox: list artifacts: %w", err) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Path < entries[j].Path }) + for _, entry := range entries { + name := path.Base(entry.Path) + relativePath := path.Join(relativeDir, name) + if entry.SymlinkTarget != nil { + return fmt.Errorf("ucloud agent sandbox: artifact symlinks are not allowed: %s", relativePath) + } + if entry.Type == ucloudsdk.EntryTypeDir { + if err := p.collectArtifacts(ctx, sbx, entry.Path, relativePath, artifacts, depth+1); err != nil { + return err + } + continue + } + if len(*artifacts) >= p.maxArtifacts { + return fmt.Errorf("ucloud agent sandbox: execution produced more than %d artifacts", p.maxArtifacts) + } + if entry.Size > int64(p.maxArtifactBytes) { + return fmt.Errorf("ucloud agent sandbox: artifact exceeds %d bytes: %s", p.maxArtifactBytes, relativePath) + } + extension := strings.ToLower(path.Ext(name)) + if _, allowed := allowedArtifactExts[extension]; !allowed { + return fmt.Errorf("ucloud agent sandbox: unsupported artifact type: %s", relativePath) + } + content, err := sbx.Files.ReadBytes(ctx, entry.Path) + if err != nil { + return fmt.Errorf("ucloud agent sandbox: read artifact %s: %w", relativePath, err) + } + mimeType := mime.TypeByExtension(extension) + if mimeType == "" { + mimeType = "application/octet-stream" + } + *artifacts = append(*artifacts, map[string]any{ + "name": relativePath, + "content_b64": base64.StdEncoding.EncodeToString(content), + "mime_type": mimeType, + "size": entry.Size, + }) + } + return nil +} + +func (p *UCloudAgentSandboxProvider) isInitialized() bool { + p.mu.RLock() + defer p.mu.RUnlock() + return p.initialized +} diff --git a/internal/agent/sandbox/ucloud_agent_sandbox_test.go b/internal/agent/sandbox/ucloud_agent_sandbox_test.go new file mode 100644 index 0000000000..97395ede4b --- /dev/null +++ b/internal/agent/sandbox/ucloud_agent_sandbox_test.go @@ -0,0 +1,269 @@ +// +// 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. +// + +package sandbox + +import ( + "context" + "strings" + "testing" +) + +func TestUCloudAgentSandboxProvider_ProviderTypeAndLanguages(t *testing.T) { + t.Parallel() + p := newUCloudAgentSandboxProviderFromConfig(map[string]any{}) + if p.ProviderType() != ProviderUCloudAgentSandbox { + t.Errorf("ProviderType = %q, want %q", p.ProviderType(), ProviderUCloudAgentSandbox) + } + + want := map[string]bool{"python": true, "javascript": true} + got := map[string]bool{} + for _, language := range p.SupportedLanguages() { + if !want[language] { + t.Errorf("unexpected language: %q", language) + } + got[language] = true + } + for language := range want { + if !got[language] { + t.Errorf("missing required language: %q", language) + } + } +} + +func TestUCloudAgentSandboxProvider_Defaults(t *testing.T) { + t.Parallel() + p := newUCloudAgentSandboxProviderFromConfig(map[string]any{}) + + if p.region != ucloudAgentSandboxDefaultRegion { + t.Errorf("region = %q, want %q", p.region, ucloudAgentSandboxDefaultRegion) + } + if p.template != ucloudAgentSandboxDefaultTemplate { + t.Errorf("template = %q, want %q", p.template, ucloudAgentSandboxDefaultTemplate) + } + if p.allowInternetAccess { + t.Error("allowInternetAccess = true, want false by default") + } + if p.insecureHTTP { + t.Error("insecureHTTP = true, want false by default") + } + if p.timeoutSec != ucloudAgentSandboxDefaultTimeout { + t.Errorf("timeoutSec = %d, want %d", p.timeoutSec, ucloudAgentSandboxDefaultTimeout) + } + if p.sandboxTimeoutSec != ucloudAgentSandboxDefaultLifetime { + t.Errorf("sandboxTimeoutSec = %d, want %d", p.sandboxTimeoutSec, ucloudAgentSandboxDefaultLifetime) + } + if p.maxOutputBytes != ucloudAgentSandboxDefaultMaxOutputBytes { + t.Errorf("maxOutputBytes = %d, want %d", p.maxOutputBytes, ucloudAgentSandboxDefaultMaxOutputBytes) + } + if p.maxArtifacts != ucloudAgentSandboxDefaultMaxArtifacts { + t.Errorf("maxArtifacts = %d, want %d", p.maxArtifacts, ucloudAgentSandboxDefaultMaxArtifacts) + } + if p.maxArtifactBytes != ucloudAgentSandboxDefaultMaxArtifactSize { + t.Errorf("maxArtifactBytes = %d, want %d", p.maxArtifactBytes, ucloudAgentSandboxDefaultMaxArtifactSize) + } +} + +func TestUCloudAgentSandboxProvider_ConfigOverrides(t *testing.T) { + t.Parallel() + p := newUCloudAgentSandboxProviderFromConfig(map[string]any{ + "api_key": "ucloud-key", + "region": "us-ca", + "domain": "sandbox.example.com", + "api_url": "https://api.example.com", + "template": "custom-template", + "allow_internet_access": true, + "insecure_http": true, + "timeout": float64(45), + "sandbox_timeout": float64(600), + "max_output_bytes": float64(2_000_000), + "max_artifacts": float64(50), + "max_artifact_bytes": float64(20_000_000), + }) + + if p.apiKey != "ucloud-key" { + t.Errorf("apiKey = %q", p.apiKey) + } + if p.region != "us-ca" || p.domain != "sandbox.example.com" || p.apiURL != "https://api.example.com" { + t.Errorf("endpoint config = region:%q domain:%q apiURL:%q", p.region, p.domain, p.apiURL) + } + if p.template != "custom-template" { + t.Errorf("template = %q", p.template) + } + if !p.allowInternetAccess || !p.insecureHTTP { + t.Errorf("boolean overrides not applied: internet=%v insecureHTTP=%v", p.allowInternetAccess, p.insecureHTTP) + } + if p.timeoutSec != 45 || p.sandboxTimeoutSec != 600 { + t.Errorf("timeouts = execution:%d sandbox:%d", p.timeoutSec, p.sandboxTimeoutSec) + } + if p.maxOutputBytes != 2_000_000 || p.maxArtifacts != 50 || p.maxArtifactBytes != 20_000_000 { + t.Errorf("limits = output:%d artifacts:%d artifactBytes:%d", p.maxOutputBytes, p.maxArtifacts, p.maxArtifactBytes) + } +} + +func TestUCloudAgentSandboxProvider_EnvOverrides(t *testing.T) { + t.Setenv("UCLOUD_SANDBOX_API_KEY", "env-key") + t.Setenv("UCLOUD_SANDBOX_REGION", "us-ca") + t.Setenv("UCLOUD_SANDBOX_TEMPLATE", "env-template") + t.Setenv("UCLOUD_SANDBOX_ALLOW_INTERNET_ACCESS", "true") + t.Setenv("UCLOUD_SANDBOX_INSECURE_HTTP", "true") + t.Setenv("UCLOUD_SANDBOX_EXECUTION_TIMEOUT", "60") + t.Setenv("UCLOUD_SANDBOX_TIMEOUT", "900") + t.Setenv("UCLOUD_SANDBOX_MAX_OUTPUT_BYTES", "2000") + t.Setenv("UCLOUD_SANDBOX_MAX_ARTIFACTS", "4") + t.Setenv("UCLOUD_SANDBOX_MAX_ARTIFACT_BYTES", "3000") + + p := newUCloudAgentSandboxProviderFromEnv() + if p.apiKey != "env-key" || p.region != "us-ca" || p.template != "env-template" { + t.Errorf("env string overrides not applied: key:%q region:%q template:%q", p.apiKey, p.region, p.template) + } + if !p.allowInternetAccess || !p.insecureHTTP { + t.Errorf("env boolean overrides not applied: internet=%v insecureHTTP=%v", p.allowInternetAccess, p.insecureHTTP) + } + if p.timeoutSec != 60 || p.sandboxTimeoutSec != 900 { + t.Errorf("env timeouts = execution:%d sandbox:%d", p.timeoutSec, p.sandboxTimeoutSec) + } + if p.maxOutputBytes != 2000 || p.maxArtifacts != 4 || p.maxArtifactBytes != 3000 { + t.Errorf("env limits = output:%d artifacts:%d artifactBytes:%d", p.maxOutputBytes, p.maxArtifacts, p.maxArtifactBytes) + } +} + +func TestUCloudAgentSandboxProvider_Initialize(t *testing.T) { + t.Parallel() + + t.Run("missing API key", func(t *testing.T) { + p := newUCloudAgentSandboxProviderFromConfig(map[string]any{}) + err := p.Initialize(context.Background()) + if err == nil { + t.Fatal("Initialize with no API key: got nil error, want one") + } + if !strings.Contains(err.Error(), "API key") { + t.Errorf("err = %v, want to mention API key", err) + } + }) + + t.Run("valid local configuration", func(t *testing.T) { + p := newUCloudAgentSandboxProviderFromConfig(map[string]any{"api_key": "fake-key"}) + if err := p.Initialize(context.Background()); err != nil { + t.Fatalf("Initialize: %v", err) + } + if !p.isInitialized() || p.client == nil { + t.Error("provider did not retain an initialized SDK client") + } + if err := p.HealthCheck(context.Background()); err != nil { + t.Errorf("HealthCheck after Initialize: %v", err) + } + }) + + t.Run("invalid limits", func(t *testing.T) { + p := newUCloudAgentSandboxProviderFromConfig(map[string]any{ + "api_key": "fake-key", + "max_artifacts": -1, + }) + if err := p.Initialize(context.Background()); err == nil { + t.Fatal("Initialize with invalid limits: got nil error, want one") + } + }) +} + +func TestUCloudAgentSandboxProvider_AllOpsBeforeInit(t *testing.T) { + t.Parallel() + p := newUCloudAgentSandboxProviderFromConfig(map[string]any{}) + inst := &SandboxInstance{InstanceID: "x", Provider: ProviderUCloudAgentSandbox} + + if _, err := p.CreateInstance(context.Background(), "python"); err == nil { + t.Error("CreateInstance before init: got nil error, want one") + } + if _, err := p.ExecuteCode(context.Background(), inst, "x", "python", 5, nil); err == nil { + t.Error("ExecuteCode before init: got nil error, want one") + } + if err := p.DestroyInstance(context.Background(), inst); err == nil { + t.Error("DestroyInstance before init: got nil error, want one") + } + if err := p.HealthCheck(context.Background()); err == nil { + t.Error("HealthCheck before init: got nil error, want one") + } +} + +func TestUCloudAgentSandboxProvider_ExecuteCodeRejectsBadInputs(t *testing.T) { + t.Parallel() + p := newUCloudAgentSandboxProviderFromConfig(map[string]any{"api_key": "fake-key"}) + if err := p.Initialize(context.Background()); err != nil { + t.Fatalf("Initialize: %v", err) + } + p.instances["x"] = &ucloudAgentSandboxInstance{} + + cases := []struct { + name string + inst *SandboxInstance + lang string + time int + want string + }{ + {name: "nil instance", inst: nil, lang: "python", time: 5, want: "instance id"}, + {name: "empty instance id", inst: &SandboxInstance{}, lang: "python", time: 5, want: "instance id"}, + {name: "unknown instance", inst: &SandboxInstance{InstanceID: "missing"}, lang: "python", time: 5, want: "unknown instance"}, + {name: "unsupported language", inst: &SandboxInstance{InstanceID: "x"}, lang: "ruby", time: 5, want: "unsupported language"}, + {name: "timeout too small", inst: &SandboxInstance{InstanceID: "x"}, lang: "python", time: 0, want: "timeout"}, + {name: "timeout too large", inst: &SandboxInstance{InstanceID: "x"}, lang: "python", time: 1000, want: "timeout"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := p.ExecuteCode(context.Background(), tc.inst, "x", tc.lang, tc.time, nil) + if err == nil { + t.Fatalf("got nil error, want one containing %q", tc.want) + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("err = %v, want to contain %q", err, tc.want) + } + }) + } +} + +func TestUCloudAgentSandboxProvider_DestroyInstanceRejectsEmptyID(t *testing.T) { + t.Parallel() + p := newUCloudAgentSandboxProviderFromConfig(map[string]any{"api_key": "fake-key"}) + if err := p.Initialize(context.Background()); err != nil { + t.Fatalf("Initialize: %v", err) + } + if err := p.DestroyInstance(context.Background(), nil); err == nil { + t.Error("DestroyInstance(nil): got nil error, want one") + } + if err := p.DestroyInstance(context.Background(), &SandboxInstance{}); err == nil { + t.Error("DestroyInstance(empty id): got nil error, want one") + } + if err := p.DestroyInstance(context.Background(), &SandboxInstance{InstanceID: "already-gone"}); err != nil { + t.Errorf("DestroyInstance(unknown id): %v, want idempotent success", err) + } +} + +func TestUCloudAgentSandboxProvider_ProviderTypeStaysDistinct(t *testing.T) { + t.Parallel() + seen := map[ProviderType]bool{} + for _, provider := range []SandboxProvider{ + newSelfManagedProviderFromEnv(), + newAliyunProviderFromEnv(), + newE2BProviderFromEnv(), + newTenkiProviderFromEnv(), + newUCloudAgentSandboxProviderFromConfig(map[string]any{}), + } { + if seen[provider.ProviderType()] { + t.Errorf("provider type %q seen twice", provider.ProviderType()) + } + seen[provider.ProviderType()] = true + } +} diff --git a/internal/common/environments.go b/internal/common/environments.go index 1b422abf53..7260cbb69f 100644 --- a/internal/common/environments.go +++ b/internal/common/environments.go @@ -68,6 +68,18 @@ const ( EnvTenkiImage = "TENKI_IMAGE" EnvTenkiTimeout = "TENKI_TIMEOUT" EnvTenkiAllowOutbound = "TENKI_ALLOW_OUTBOUND" + EnvUCloudSandboxAPIKey = "UCLOUD_SANDBOX_API_KEY" + EnvUCloudSandboxRegion = "UCLOUD_SANDBOX_REGION" + EnvUCloudSandboxDomain = "UCLOUD_SANDBOX_DOMAIN" + EnvUCloudSandboxAPIURL = "UCLOUD_SANDBOX_API_URL" + EnvUCloudSandboxTemplate = "UCLOUD_SANDBOX_TEMPLATE" + EnvUCloudSandboxAllowInternetAccess = "UCLOUD_SANDBOX_ALLOW_INTERNET_ACCESS" + EnvUCloudSandboxInsecureHTTP = "UCLOUD_SANDBOX_INSECURE_HTTP" + EnvUCloudSandboxExecutionTimeout = "UCLOUD_SANDBOX_EXECUTION_TIMEOUT" + EnvUCloudSandboxTimeout = "UCLOUD_SANDBOX_TIMEOUT" + EnvUCloudSandboxMaxOutputBytes = "UCLOUD_SANDBOX_MAX_OUTPUT_BYTES" + EnvUCloudSandboxMaxArtifacts = "UCLOUD_SANDBOX_MAX_ARTIFACTS" + EnvUCloudSandboxMaxArtifactBytes = "UCLOUD_SANDBOX_MAX_ARTIFACT_BYTES" EnvLocalPythonBin = "LOCAL_PYTHON_BIN" EnvLocalNodeBin = "LOCAL_NODE_BIN" EnvLocalWorkDir = "LOCAL_WORK_DIR" diff --git a/pyproject.toml b/pyproject.toml index 75a1386ca7..b81e1f542b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,6 +130,7 @@ dependencies = [ "tavily-python==0.5.1", "tencentcloud-sdk-python==3.0.1478", "tika==2.6.0", + "ucloud-sandbox>=1.4.2,<2.0.0", "valkey==6.0.2", "volcengine==1.0.194", "voyageai==0.2.3", diff --git a/test/unit_test/agent/sandbox/test_ucloud_agent_sandbox_provider.py b/test/unit_test/agent/sandbox/test_ucloud_agent_sandbox_provider.py new file mode 100644 index 0000000000..1fbb01abf3 --- /dev/null +++ b/test/unit_test/agent/sandbox/test_ucloud_agent_sandbox_provider.py @@ -0,0 +1,358 @@ +import base64 +import posixpath +from types import SimpleNamespace + +import pytest + +from agent.sandbox.providers.base import SandboxProviderConfigError +from agent.sandbox.providers.ucloud_agent_sandbox import UCloudAgentSandboxProvider +from agent.sandbox.result_protocol import RESULT_MARKER_PREFIX + +pytestmark = pytest.mark.p3 + + +class _FakeFileType: + FILE = "file" + DIR = "dir" + + +class _AuthenticationException(Exception): + pass + + +class _RateLimitException(Exception): + pass + + +class _TimeoutException(Exception): + pass + + +class _FileNotFoundException(Exception): + pass + + +class _CommandExitException(Exception): + def __init__(self, exit_code: int, stdout: str = "", stderr: str = "", error: str = ""): + super().__init__(error or f"command exited with code {exit_code}") + self.exit_code = exit_code + self.stdout = stdout + self.stderr = stderr + self.error = error + + +class _FakeCommandResult: + def __init__(self, exit_code: int = 0, stdout: str = "", stderr: str = ""): + self.exit_code = exit_code + self.stdout = stdout + self.stderr = stderr + + +class _FakeFiles: + def __init__(self): + self.files: dict[str, bytes] = {} + self.list_overrides: dict[str, list[SimpleNamespace]] = {} + + def write(self, path: str, data: str | bytes, request_timeout=None): + self.files[path] = data.encode("utf-8") if isinstance(data, str) else data + return SimpleNamespace(path=path) + + def read(self, path: str, format: str = "text", request_timeout=None): + payload = self.files[path] + if format == "bytes": + return bytearray(payload) + return payload.decode("utf-8") + + def list(self, path: str, depth: int = 1, request_timeout=None): + if path in self.list_overrides: + return self.list_overrides[path] + + prefix = path.rstrip("/") + "/" + entries: list[SimpleNamespace] = [] + seen_dirs: set[str] = set() + for file_path, payload in self.files.items(): + if not file_path.startswith(prefix): + continue + relative = file_path[len(prefix) :] + head, _, tail = relative.partition("/") + entry_path = posixpath.join(path, head) + if tail: + if head not in seen_dirs: + seen_dirs.add(head) + entries.append( + SimpleNamespace( + path=entry_path, + size=0, + type=_FakeFileType.DIR, + symlink_target=None, + ) + ) + continue + entries.append( + SimpleNamespace( + path=entry_path, + size=len(payload), + type=_FakeFileType.FILE, + symlink_target=None, + ) + ) + return entries + + +class _FakeCommands: + def __init__(self, sandbox, run_handler=None): + self._sandbox = sandbox + self._run_handler = run_handler + self.calls: list[dict] = [] + + def run(self, cmd: str, cwd=None, timeout=None, request_timeout=None): + self.calls.append( + { + "cmd": cmd, + "cwd": cwd, + "timeout": timeout, + "request_timeout": request_timeout, + } + ) + if cmd.startswith("mkdir -p "): + return _FakeCommandResult() + if self._run_handler is not None: + return self._run_handler(self._sandbox, cmd, cwd) + return _FakeCommandResult() + + +class _FakeSandbox: + def __init__(self, run_handler=None): + self.sandbox_id = "sbx-fake-1" + self.files = _FakeFiles() + self.commands = _FakeCommands(self, run_handler) + self.timeout_calls: list[tuple[int, int | None]] = [] + self.killed = False + + def set_timeout(self, timeout: int, request_timeout=None): + self.timeout_calls.append((timeout, request_timeout)) + + def kill(self, request_timeout=None): + self.killed = True + return True + + +class _FakeSDK: + AuthenticationException = _AuthenticationException + RateLimitException = _RateLimitException + TimeoutException = _TimeoutException + FileNotFoundException = _FileNotFoundException + CommandExitException = _CommandExitException + FileType = _FakeFileType + + def __init__(self, sandbox: _FakeSandbox): + self._sandbox = sandbox + self.create_kwargs: dict | None = None + self.create_error: Exception | None = None + self.Sandbox = SimpleNamespace(create=self._create) + + def _create(self, **kwargs): + self.create_kwargs = kwargs + if self.create_error is not None: + raise self.create_error + return self._sandbox + + +def _build_provider(sandbox: _FakeSandbox, monkeypatch, **overrides) -> tuple[UCloudAgentSandboxProvider, _FakeSDK]: + sdk = _FakeSDK(sandbox) + monkeypatch.setattr("agent.sandbox.providers.ucloud_agent_sandbox._get_ucloud_sandbox_module", lambda: sdk) + config = { + "api_key": "ucloud-test-key", + "region": "cn-wlcb", + "template": "base", + "timeout": 30, + "sandbox_timeout": 300, + "max_output_bytes": 1024 * 1024, + "max_artifacts": 20, + "max_artifact_bytes": 1024 * 1024, + } + config.update(overrides) + provider = UCloudAgentSandboxProvider() + assert provider.initialize(config) is True + return provider, sdk + + +def test_ucloud_agent_sandbox_executes_python_and_collects_artifacts(monkeypatch): + def run_handler(sandbox, cmd, cwd): + sandbox.files.files[posixpath.join(cwd, "artifacts", "chart.png")] = b"PNGDATA" + payload = base64.b64encode(b'{"present":true,"value":{"message":"hello ucloud"},"type":"json"}').decode("ascii") + return _FakeCommandResult(stdout=f"debug line\n{RESULT_MARKER_PREFIX}{payload}\n") + + sandbox = _FakeSandbox(run_handler) + provider, sdk = _build_provider(sandbox, monkeypatch) + + instance = provider.create_instance("python") + result = provider.execute_code( + instance.instance_id, + 'def main() -> dict:\n return {"message": "hello ucloud"}\n', + "python", + timeout=5, + ) + provider.destroy_instance(instance.instance_id) + + assert instance.provider == "ucloud_agent_sandbox" + assert instance.metadata["sandbox_id"] == "sbx-fake-1" + assert sdk.create_kwargs["template"] == "base" + assert sdk.create_kwargs["allow_internet_access"] is False + assert sdk.create_kwargs["secure"] is True + assert sdk.create_kwargs["domain"] == "cn-wlcb.sandbox.ucloudai.com" + 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 ucloud"} + assert result.metadata["artifacts"] == [ + { + "name": "chart.png", + "content_b64": base64.b64encode(b"PNGDATA").decode("ascii"), + "mime_type": "image/png", + "size": 7, + } + ] + assert sandbox.timeout_calls == [(300, 30)] + assert sandbox.killed is True + + +def test_ucloud_agent_sandbox_preserves_nonzero_exit(monkeypatch): + sandbox = _FakeSandbox() + provider, sdk = _build_provider(sandbox, monkeypatch) + + def run_handler(sandbox, cmd, cwd): + raise sdk.CommandExitException(7, stderr="boom\n", error="failed") + + sandbox.commands._run_handler = run_handler + instance = provider.create_instance("python") + result = provider.execute_code(instance.instance_id, "def main():\n raise SystemExit(7)\n", "python", timeout=5) + + assert result.exit_code == 7 + assert result.stderr == "boom\n" + assert result.metadata["status"] == "error" + assert result.metadata["result_present"] is False + + +def test_ucloud_agent_sandbox_maps_timeout(monkeypatch): + sandbox = _FakeSandbox() + provider, sdk = _build_provider(sandbox, monkeypatch) + + def run_handler(sandbox, cmd, cwd): + raise sdk.TimeoutException("deadline exceeded") + + sandbox.commands._run_handler = run_handler + instance = provider.create_instance("python") + with pytest.raises(TimeoutError, match="timed out after 5 seconds"): + provider.execute_code(instance.instance_id, "def main():\n return 1\n", "python", timeout=5) + + +def test_ucloud_agent_sandbox_maps_create_errors(monkeypatch): + sandbox = _FakeSandbox() + provider, sdk = _build_provider(sandbox, monkeypatch) + + sdk.create_error = sdk.AuthenticationException("bad key") + with pytest.raises(SandboxProviderConfigError, match="authentication failed"): + provider.create_instance("python") + + sdk.create_error = sdk.RateLimitException("slow down") + with pytest.raises(RuntimeError, match="rate limited"): + provider.create_instance("python") + + +def test_ucloud_agent_sandbox_executes_javascript(monkeypatch): + def run_handler(sandbox, cmd, cwd): + payload = base64.b64encode(b'{"present":true,"value":42,"type":"json"}').decode("ascii") + return _FakeCommandResult(stdout=f"{RESULT_MARKER_PREFIX}{payload}\n") + + sandbox = _FakeSandbox(run_handler) + provider, _ = _build_provider(sandbox, monkeypatch) + instance = provider.create_instance("javascript") + result = provider.execute_code(instance.instance_id, "function main(args) { return 42; }", "javascript", timeout=5) + + assert instance.metadata["language"] == "nodejs" + assert any(path.endswith("main.js") for path in sandbox.files.files) + assert any(call["cmd"].startswith("node ") for call in sandbox.commands.calls) + assert result.metadata["result_value"] == 42 + + +def test_ucloud_agent_sandbox_config_schema_and_validation(): + schema = UCloudAgentSandboxProvider.get_config_schema() + assert schema["api_key"]["required"] is True + assert schema["api_key"]["secret"] is True + assert schema["template"]["default"] == "base" + assert schema["allow_internet_access"]["default"] is False + assert schema["region"]["default"] == "cn-wlcb" + + provider = UCloudAgentSandboxProvider() + ok, message = provider.validate_config( + { + "api_key": "key", + "template": "base", + "timeout": 30, + "sandbox_timeout": 300, + "max_output_bytes": 1024, + "max_artifacts": 5, + "max_artifact_bytes": 1024, + } + ) + assert ok is True + assert message is None + + ok, message = provider.validate_config({"api_key": ""}) + assert ok is False + assert "API key" in message + + +def test_ucloud_agent_sandbox_enforces_output_limit(monkeypatch): + sandbox = _FakeSandbox(lambda sandbox, cmd, cwd: _FakeCommandResult(stdout="x" * 5000)) + provider, _ = _build_provider(sandbox, monkeypatch, max_output_bytes=100) + instance = provider.create_instance("python") + + with pytest.raises(RuntimeError, match="output exceeded"): + provider.execute_code(instance.instance_id, "def main():\n return 1\n", "python", timeout=5) + + +def test_ucloud_agent_sandbox_rejects_disallowed_artifact(monkeypatch): + def run_handler(sandbox, cmd, cwd): + sandbox.files.files[posixpath.join(cwd, "artifacts", "malware.exe")] = b"MZ" + return _FakeCommandResult() + + sandbox = _FakeSandbox(run_handler) + provider, _ = _build_provider(sandbox, monkeypatch) + instance = provider.create_instance("python") + + with pytest.raises(RuntimeError, match="Unsupported artifact type"): + provider.execute_code(instance.instance_id, "def main():\n return 1\n", "python", timeout=5) + + +def test_ucloud_agent_sandbox_rejects_symlink_artifact(monkeypatch): + def run_handler(sandbox, cmd, cwd): + artifacts_dir = posixpath.join(cwd, "artifacts") + sandbox.files.list_overrides[artifacts_dir] = [ + SimpleNamespace( + path=posixpath.join(artifacts_dir, "evil.json"), + size=10, + type=_FakeFileType.FILE, + symlink_target="/etc/passwd", + ) + ] + return _FakeCommandResult() + + sandbox = _FakeSandbox(run_handler) + provider, _ = _build_provider(sandbox, monkeypatch) + instance = provider.create_instance("python") + + with pytest.raises(RuntimeError, match="symlinks are not allowed"): + provider.execute_code(instance.instance_id, "def main():\n return 1\n", "python", timeout=5) + + +def test_ucloud_agent_sandbox_destroy_is_idempotent(monkeypatch): + sandbox = _FakeSandbox() + provider, _ = _build_provider(sandbox, monkeypatch) + + assert provider.destroy_instance("already-gone") is True + + +def test_ucloud_agent_sandbox_supported_languages(): + assert UCloudAgentSandboxProvider().get_supported_languages() == ["python", "javascript"] diff --git a/uv.lock b/uv.lock index ade026ef78..5dfd36b604 100644 --- a/uv.lock +++ b/uv.lock @@ -948,6 +948,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/05/af/fec6a530efdfc3d7739d821cdcb63de7c9979954fa21ef6d16d0b678c8ed/boxsdk-10.3.0-py3-none-any.whl", hash = "sha256:3f65792834315177765c096402e35f43400c4c99c9b6e82f9ac40c8de3da4767" }, ] +[[package]] +name = "bracex" +version = "3.0.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ac/01/5f394b8bcd6e5b92f73130990960423bbb19711f906bd9fe9ea5557c667c/bracex-3.0.1.tar.gz", hash = "sha256:4e38e32392e4a4780fe15d644bfc7c8514057cfc3861e060b11814ce829c25e4" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b8/8f/6f7273a7adb8d73fc8d21ede4376a3e475e52f98435c6007f69100dec8ca/bracex-3.0.1-py3-none-any.whl", hash = "sha256:6523ad83aeb5098a4ee597cff0f964442ff74e460bd3fafaffab6a013ff2288c" }, +] + [[package]] name = "brotli" version = "1.2.0" @@ -1843,6 +1852,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/cf/cd/b71d5bc74cde7fc6fd9b2ff9389890f45d9762cbbbf81dc5e51fd7588c4a/elastic_transport-8.17.1-py3-none-any.whl", hash = "sha256:192718f498f1d10c5e9aa8b9cf32aed405e469a7f0e9d6a8923431dbb2c59fb8" }, ] +[[package]] +name = "dockerfile-parse" +version = "2.0.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/92/df/929ee0b5d2c8bd8d713c45e71b94ab57c7e11e322130724d54f469b2cd48/dockerfile-parse-2.0.1.tar.gz", hash = "sha256:3184ccdc513221983e503ac00e1aa504a2aa8f84e5de673c46b0b6eee99ec7bc" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/7a/6c/79cd5bc1b880d8c1a9a5550aa8dacd57353fa3bb2457227e1fb47383eb49/dockerfile_parse-2.0.1-py2.py3-none-any.whl", hash = "sha256:bdffd126d2eb26acf1066acb54cb2e336682e1d72b974a40894fac76a4df17f6" }, +] + [[package]] name = "elasticsearch" version = "8.19.3" @@ -8223,6 +8241,7 @@ dependencies = [ { name = "tavily-python" }, { name = "tencentcloud-sdk-python" }, { name = "tika" }, + { name = "ucloud-sandbox" }, { name = "valkey" }, { name = "volcengine" }, { name = "voyageai" }, @@ -8387,6 +8406,7 @@ requires-dist = [ { name = "tavily-python", specifier = "==0.5.1" }, { name = "tencentcloud-sdk-python", specifier = "==3.0.1478" }, { name = "tika", specifier = "==2.6.0" }, + { name = "ucloud-sandbox", specifier = ">=1.4.2,<2.0.0" }, { name = "valkey", specifier = "==6.0.2" }, { name = "volcengine", specifier = "==1.0.194" }, { name = "voyageai", specifier = "==0.2.3" }, @@ -9799,6 +9819,30 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d" }, ] +[[package]] +name = "ucloud-sandbox" +version = "1.4.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "attrs" }, + { name = "dockerfile-parse" }, + { name = "h2" }, + { name = "httpcore" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "rich" }, + { name = "typing-extensions" }, + { name = "wcmatch" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/99/a7/d708ea65985c30f84a0cee728e07e7c729364e9136483e46ecbc7f4d6525/ucloud_sandbox-1.4.2.tar.gz", hash = "sha256:671eb59dfd610456030a97e56b2735972089857dd7ec4f013d3b4291b181c469" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/b9/cd/7d6e4a70737afae28bc1b83ada67dd2aea9deaf8d003f23e4c94c7160d92/ucloud_sandbox-1.4.2-py3-none-any.whl", hash = "sha256:6b7e070e90a21744b2d3080e472775645beeed6667666b4b74b7de6f61c452d1" }, +] + [[package]] name = "umap-learn" version = "0.5.9.post2" @@ -9989,6 +10033,18 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/0a/07/57ebf7a6798b016c064bd0ca81b4c6a99daa4dc377b898bc7b41eb6b5af0/weasel-1.0.0-py3-none-any.whl", hash = "sha256:89518acee027f49d743126c3502d35e6dd14f5768be5c37c9af47c171b6005cc" }, ] +[[package]] +name = "wcmatch" +version = "10.2.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/11/15/dc61746d8c0852f6d711ad09c774b63cf7c8211aa49e30871ac3d342b7e2/wcmatch-10.2.1.tar.gz", hash = "sha256:ecac70a5c70e62ba854b78318d3a1408e8651f8f1c96e5837743b71aa6a4fb92" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/82/ba/20b48eedeab5316bf9a502bb9eb7e3b1588bd61d0f565822fefa8f06e10b/wcmatch-10.2.1-py3-none-any.whl", hash = "sha256:2d775395b93f233af66690f62cb9d52b084ec159a31cc4084f4069d72f437acd" }, +] + [[package]] name = "webdav4" version = "0.10.0" diff --git a/web/src/pages/admin/sandbox-settings.tsx b/web/src/pages/admin/sandbox-settings.tsx index 476eb8338c..b58d27ec01 100644 --- a/web/src/pages/admin/sandbox-settings.tsx +++ b/web/src/pages/admin/sandbox-settings.tsx @@ -64,6 +64,7 @@ const PROVIDER_ICONS: Record = { aliyun_codeinterpreter: LucideCloud, e2b: LucideZap, tenki: LucideCloudLightning, + ucloud_agent_sandbox: LucideCloud, }; function AdminSandboxSettings() {