mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-29 04:08:12 +08:00
feat: add Tenki sandbox provider (#17305)
### Summary Adds a `tenki` sandbox provider that runs each agent code execution in a disposable Tenki (https://tenki.cloud) microVM (create → exec → destroy, no volumes or snapshots). Registration mirrors PR #15039, configure `api_key` and `project_id` in Admin > Sandbox Settings. Both runtimes are covered: - Python: `agent/sandbox/providers/tenki.py` (structured results + artifact collection). - Go: `internal/agent/sandbox/tenki.go`, mirroring the e2b provider and wired into the provider manager. `tenki-sandbox` is an optional dependency (it requires `protobuf>=6.31`, which differs from RAGFlow's pinned gRPC stack), lazily imported with a clear error when missing; installation is documented in the sandbox quickstart. Unit tests cover execution, structured results, artifacts (symlink/size/extension limits), non-zero exit, timeout, error mapping, and idempotent destroy. --------- Co-authored-by: yiming.wang <yiming.wang@luxor.com>
This commit is contained in:
@@ -484,6 +484,11 @@ class SandboxMgr:
|
||||
"description": "E2B Cloud - Code Execution Sandboxes",
|
||||
"tags": ["saas", "fast", "global"],
|
||||
},
|
||||
"tenki": {
|
||||
"name": "Tenki",
|
||||
"description": "Tenki - Disposable microVM code sandboxes",
|
||||
"tags": ["saas", "cloud", "microvm", "isolated"],
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -503,6 +508,7 @@ class SandboxMgr:
|
||||
SSHProvider,
|
||||
AliyunCodeInterpreterProvider,
|
||||
E2BProvider,
|
||||
TenkiProvider,
|
||||
)
|
||||
|
||||
schemas = {
|
||||
@@ -511,6 +517,7 @@ class SandboxMgr:
|
||||
"ssh": SSHProvider.get_config_schema(),
|
||||
"aliyun_codeinterpreter": AliyunCodeInterpreterProvider.get_config_schema(),
|
||||
"e2b": E2BProvider.get_config_schema(),
|
||||
"tenki": TenkiProvider.get_config_schema(),
|
||||
}
|
||||
|
||||
if provider_id not in schemas:
|
||||
@@ -576,6 +583,7 @@ class SandboxMgr:
|
||||
SSHProvider,
|
||||
AliyunCodeInterpreterProvider,
|
||||
E2BProvider,
|
||||
TenkiProvider,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -620,6 +628,7 @@ class SandboxMgr:
|
||||
"ssh": SSHProvider,
|
||||
"aliyun_codeinterpreter": AliyunCodeInterpreterProvider,
|
||||
"e2b": E2BProvider,
|
||||
"tenki": TenkiProvider,
|
||||
}
|
||||
provider = provider_classes[provider_type]()
|
||||
is_valid, error_msg = provider.validate_config(config)
|
||||
@@ -667,6 +676,7 @@ class SandboxMgr:
|
||||
SSHProvider,
|
||||
AliyunCodeInterpreterProvider,
|
||||
E2BProvider,
|
||||
TenkiProvider,
|
||||
)
|
||||
|
||||
# Instantiate provider based on type
|
||||
@@ -676,6 +686,7 @@ class SandboxMgr:
|
||||
"ssh": SSHProvider,
|
||||
"aliyun_codeinterpreter": AliyunCodeInterpreterProvider,
|
||||
"e2b": E2BProvider,
|
||||
"tenki": TenkiProvider,
|
||||
}
|
||||
|
||||
if provider_type not in provider_classes:
|
||||
|
||||
@@ -77,6 +77,7 @@ def _load_provider_from_settings() -> None:
|
||||
E2BProvider,
|
||||
LocalProvider,
|
||||
SSHProvider,
|
||||
TenkiProvider,
|
||||
)
|
||||
|
||||
provider_classes = {
|
||||
@@ -85,6 +86,7 @@ def _load_provider_from_settings() -> None:
|
||||
"e2b": E2BProvider,
|
||||
"local": LocalProvider,
|
||||
"ssh": SSHProvider,
|
||||
"tenki": TenkiProvider,
|
||||
}
|
||||
|
||||
if provider_type not in provider_classes:
|
||||
@@ -97,7 +99,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"}:
|
||||
if provider_type in {"local", "ssh", "tenki"}:
|
||||
raise SandboxProviderConfigError(message)
|
||||
logger.error(message)
|
||||
return
|
||||
|
||||
@@ -26,6 +26,7 @@ This package contains:
|
||||
- e2b.py: E2B provider implementation
|
||||
- local.py: Local process provider implementation
|
||||
- ssh.py: Remote SSH provider implementation
|
||||
- tenki.py: Tenki disposable microVM provider implementation
|
||||
"""
|
||||
|
||||
from .base import SandboxProvider, SandboxInstance, ExecutionResult, SandboxProviderConfigError
|
||||
@@ -35,6 +36,7 @@ from .aliyun_codeinterpreter import AliyunCodeInterpreterProvider
|
||||
from .e2b import E2BProvider
|
||||
from .local import LocalProvider
|
||||
from .ssh import SSHProvider
|
||||
from .tenki import TenkiProvider
|
||||
|
||||
__all__ = [
|
||||
"SandboxProvider",
|
||||
@@ -47,4 +49,5 @@ __all__ = [
|
||||
"E2BProvider",
|
||||
"LocalProvider",
|
||||
"SSHProvider",
|
||||
"TenkiProvider",
|
||||
]
|
||||
|
||||
535
agent/sandbox/providers/tenki.py
Normal file
535
agent/sandbox/providers/tenki.py
Normal file
@@ -0,0 +1,535 @@
|
||||
#
|
||||
# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import posixpath
|
||||
import stat
|
||||
import time
|
||||
import uuid
|
||||
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,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ALLOWED_ARTIFACT_EXTENSIONS = {
|
||||
".csv",
|
||||
".html",
|
||||
".jpeg",
|
||||
".jpg",
|
||||
".json",
|
||||
".pdf",
|
||||
".png",
|
||||
".svg",
|
||||
}
|
||||
|
||||
# Base directory inside the Tenki sandbox. The default image runs as the
|
||||
# unprivileged "tenki" user whose home is writable.
|
||||
SANDBOX_HOME = "/home/tenki"
|
||||
|
||||
# Maximum directory depth walked when collecting artifacts, guarding against
|
||||
# deeply nested or symlink-looped trees produced by untrusted code.
|
||||
MAX_ARTIFACT_DEPTH = 16
|
||||
|
||||
|
||||
class TenkiProvider(SandboxProvider):
|
||||
"""Execute code in a disposable Tenki microVM.
|
||||
|
||||
Each create_instance() provisions a fresh sandbox, execute_code() runs a
|
||||
single script in it, and destroy_instance() terminates it. Only the stable
|
||||
create/exec/destroy path is used; no volumes or snapshots.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = ""
|
||||
self.project_id = ""
|
||||
self.base_url = ""
|
||||
self.image = ""
|
||||
self.allow_outbound = False
|
||||
self.timeout = 30
|
||||
self.max_lifetime = 3600
|
||||
self.cpu_cores = 0
|
||||
self.memory_mb = 0
|
||||
self.disk_size_gb = 0
|
||||
self.max_output_bytes = 1024 * 1024
|
||||
self.max_artifacts = 20
|
||||
self.max_artifact_bytes = 10 * 1024 * 1024
|
||||
self._initialized = False
|
||||
self._client = None
|
||||
self._instances: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def initialize(self, config: Dict[str, Any]) -> bool:
|
||||
self.api_key = str(config.get("api_key", "") or "").strip()
|
||||
self.project_id = str(config.get("project_id", "") or "").strip()
|
||||
self.base_url = str(config.get("base_url", "") or "").strip()
|
||||
self.image = str(config.get("image", "") or "").strip()
|
||||
self.allow_outbound = bool(config.get("allow_outbound", False))
|
||||
self.timeout = int(config.get("timeout", 30) or 30)
|
||||
self.max_lifetime = int(config.get("max_lifetime", 3600) or 3600)
|
||||
self.cpu_cores = int(config.get("cpu_cores", 0) or 0)
|
||||
self.memory_mb = int(config.get("memory_mb", 0) or 0)
|
||||
self.disk_size_gb = int(config.get("disk_size_gb", 0) or 0)
|
||||
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(
|
||||
{
|
||||
"api_key": self.api_key,
|
||||
"project_id": self.project_id,
|
||||
"timeout": self.timeout,
|
||||
"max_lifetime": self.max_lifetime,
|
||||
"max_output_bytes": self.max_output_bytes,
|
||||
"max_artifacts": self.max_artifacts,
|
||||
"max_artifact_bytes": self.max_artifact_bytes,
|
||||
}
|
||||
)
|
||||
if not is_valid:
|
||||
raise SandboxProviderConfigError(error_message or "Invalid Tenki provider configuration.")
|
||||
|
||||
self._client = self._create_client()
|
||||
self._assert_connectivity()
|
||||
|
||||
self._initialized = True
|
||||
logger.info("Tenki provider initialized")
|
||||
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)
|
||||
errors = self._tenki_errors()
|
||||
|
||||
create_kwargs: dict[str, Any] = {
|
||||
"project_id": self.project_id,
|
||||
"allow_outbound": self.allow_outbound,
|
||||
"max_duration": self.max_lifetime,
|
||||
"metadata": {"source": "ragflow"},
|
||||
}
|
||||
if self.image:
|
||||
create_kwargs["image"] = self.image
|
||||
if self.cpu_cores > 0:
|
||||
create_kwargs["cpu_cores"] = self.cpu_cores
|
||||
if self.memory_mb > 0:
|
||||
create_kwargs["memory_mb"] = self.memory_mb
|
||||
if self.disk_size_gb > 0:
|
||||
create_kwargs["disk_size_gb"] = self.disk_size_gb
|
||||
|
||||
try:
|
||||
sandbox = self._client.create(**create_kwargs)
|
||||
except errors.QuotaExceededError as exc:
|
||||
raise RuntimeError(f"Tenki quota exceeded: {exc}") from exc
|
||||
except errors.RateLimitedError as exc:
|
||||
raise RuntimeError(f"Tenki rate limited, please retry: {exc}") from exc
|
||||
except errors.UnauthorizedError as exc:
|
||||
raise SandboxProviderConfigError("Tenki authentication failed: check the API key.") from exc
|
||||
except Exception as exc:
|
||||
# Satisfy the base contract: any other SDK failure becomes RuntimeError.
|
||||
raise RuntimeError(f"Failed to create Tenki sandbox: {exc}") from exc
|
||||
|
||||
remote_work_dir = posixpath.join(SANDBOX_HOME, f"ragflow-codeexec-{uuid.uuid4().hex}")
|
||||
try:
|
||||
result = sandbox.exec(
|
||||
"mkdir",
|
||||
"-p",
|
||||
posixpath.join(remote_work_dir, "artifacts"),
|
||||
timeout=min(self.timeout, 10),
|
||||
)
|
||||
if result.exit_code != 0:
|
||||
raise RuntimeError(f"Failed to create sandbox workspace: {result.stderr_text or 'unknown error'}")
|
||||
except Exception:
|
||||
self._safe_terminate(sandbox)
|
||||
raise
|
||||
|
||||
instance_id = str(uuid.uuid4())
|
||||
self._instances[instance_id] = {
|
||||
"sandbox": sandbox,
|
||||
"remote_work_dir": remote_work_dir,
|
||||
"language": language,
|
||||
}
|
||||
logger.info("Tenki sandbox created (instance=%s, session=%s)", instance_id, sandbox.id)
|
||||
|
||||
return SandboxInstance(
|
||||
instance_id=instance_id,
|
||||
provider="tenki",
|
||||
status="running",
|
||||
metadata={"language": language, "remote_work_dir": remote_work_dir, "sandbox_id": sandbox.id},
|
||||
)
|
||||
|
||||
def execute_code(
|
||||
self,
|
||||
instance_id: str,
|
||||
code: str,
|
||||
language: str,
|
||||
timeout: int = 10,
|
||||
arguments: Optional[Dict[str, Any]] = None,
|
||||
) -> ExecutionResult:
|
||||
if not self._initialized:
|
||||
raise RuntimeError("Provider not initialized. Call initialize() first.")
|
||||
if instance_id not in self._instances:
|
||||
raise RuntimeError(f"Unknown Tenki 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"]
|
||||
errors = self._tenki_errors()
|
||||
|
||||
args_json = json.dumps(arguments or {}, ensure_ascii=False)
|
||||
script_path, argv = self._prepare_script(sandbox, remote_work_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()
|
||||
try:
|
||||
result = sandbox.exec(*argv, cwd=remote_work_dir, timeout=exec_timeout)
|
||||
except (errors.CommandTimeoutError, errors.PrimitiveTimeoutError) as exc:
|
||||
logger.warning("Tenki execution timed out (instance=%s, timeout=%ss)", instance_id, exec_timeout)
|
||||
raise TimeoutError(f"Execution timed out after {exec_timeout} seconds") from exc
|
||||
except (errors.SessionTerminatedError, errors.SessionNotFoundError) as exc:
|
||||
# The sandbox was reclaimed (e.g. max_lifetime) or lost mid-run;
|
||||
# surface it as RuntimeError per the base contract, not an SDK type.
|
||||
raise RuntimeError(f"Tenki sandbox is no longer available: {exc}") from exc
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Tenki execution failed: {exc}") from exc
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
stdout = result.stdout_text
|
||||
stderr = result.stderr_text
|
||||
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,
|
||||
"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:
|
||||
if not self._initialized:
|
||||
raise RuntimeError("Provider not initialized. Call initialize() first.")
|
||||
if instance_id not in self._instances:
|
||||
return True
|
||||
|
||||
instance = self._instances.pop(instance_id)
|
||||
self._safe_terminate(instance["sandbox"])
|
||||
logger.info("Tenki sandbox destroyed (instance=%s)", instance_id)
|
||||
return True
|
||||
|
||||
def health_check(self) -> bool:
|
||||
try:
|
||||
self._assert_connectivity()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_supported_languages(self) -> List[str]:
|
||||
return ["python", "javascript"]
|
||||
|
||||
@staticmethod
|
||||
def get_config_schema() -> Dict[str, Dict]:
|
||||
return {
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"label": "API Key",
|
||||
"secret": True,
|
||||
"placeholder": "tk_...",
|
||||
"description": "Tenki API key. Create one at https://app.tenki.cloud under API Keys.",
|
||||
},
|
||||
"project_id": {
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"label": "Project ID",
|
||||
"placeholder": "Tenki project UUID",
|
||||
"description": "Tenki project that sandboxes are created under.",
|
||||
},
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"label": "API Endpoint",
|
||||
"placeholder": "https://api.tenki.cloud",
|
||||
"description": "Override the Tenki API endpoint. Leave empty for the default.",
|
||||
},
|
||||
"image": {
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"label": "Sandbox Image",
|
||||
"description": "Base image for sandboxes. Empty uses the Tenki default image (includes python3 and node).",
|
||||
},
|
||||
"allow_outbound": {
|
||||
"type": "boolean",
|
||||
"required": False,
|
||||
"label": "Allow Outbound Network",
|
||||
"default": False,
|
||||
"description": "Security-relevant. Disabled by default so sandboxed code has no network access. Enable it to let code make outbound connections (e.g. to install packages).",
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"required": False,
|
||||
"label": "Timeout (seconds)",
|
||||
"default": 30,
|
||||
"min": 1,
|
||||
"max": 600,
|
||||
"description": "Maximum execution time for a single run.",
|
||||
},
|
||||
"max_lifetime": {
|
||||
"type": "integer",
|
||||
"required": False,
|
||||
"label": "Max Sandbox Lifetime (seconds)",
|
||||
"default": 3600,
|
||||
"min": 60,
|
||||
"max": 86400,
|
||||
"description": "Tenki reclaims a sandbox after this, guarding against leaks if the run is interrupted.",
|
||||
},
|
||||
"cpu_cores": {
|
||||
"type": "integer",
|
||||
"required": False,
|
||||
"label": "vCPU Cores",
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 16,
|
||||
"description": "vCPU per sandbox. 0 uses the Tenki default.",
|
||||
},
|
||||
"memory_mb": {
|
||||
"type": "integer",
|
||||
"required": False,
|
||||
"label": "Memory (MB)",
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 65536,
|
||||
"description": "Memory per sandbox in MB. 0 uses the Tenki default.",
|
||||
},
|
||||
"disk_size_gb": {
|
||||
"type": "integer",
|
||||
"required": False,
|
||||
"label": "Disk Size (GB)",
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"description": "Disk size per sandbox in GB. 0 uses the Tenki default.",
|
||||
},
|
||||
"max_output_bytes": {
|
||||
"type": "integer",
|
||||
"required": False,
|
||||
"label": "Max Output Bytes",
|
||||
"default": 1048576,
|
||||
"min": 1024,
|
||||
"max": 10485760,
|
||||
"description": "Maximum combined stdout and stderr size.",
|
||||
},
|
||||
"max_artifacts": {
|
||||
"type": "integer",
|
||||
"required": False,
|
||||
"label": "Max Artifacts",
|
||||
"default": 20,
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"description": "Maximum number of files collected from the sandbox artifacts directory.",
|
||||
},
|
||||
"max_artifact_bytes": {
|
||||
"type": "integer",
|
||||
"required": False,
|
||||
"label": "Max Artifact Bytes",
|
||||
"default": 10485760,
|
||||
"min": 1024,
|
||||
"max": 104857600,
|
||||
"description": "Maximum size of a single artifact file in bytes.",
|
||||
},
|
||||
}
|
||||
|
||||
def validate_config(self, config: Dict[str, Any]) -> tuple[bool, Optional[str]]:
|
||||
api_key = str(config.get("api_key", "") or "").strip()
|
||||
project_id = str(config.get("project_id", "") or "").strip()
|
||||
|
||||
if not api_key:
|
||||
return False, "Tenki API key is required"
|
||||
if not project_id:
|
||||
return False, "Tenki project_id is required"
|
||||
|
||||
for key in ("timeout", "max_lifetime", "max_output_bytes", "max_artifacts", "max_artifact_bytes"):
|
||||
try:
|
||||
value = int(config.get(key, 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return False, f"{key} must be an integer"
|
||||
if key == "max_artifacts":
|
||||
if value < 0:
|
||||
return False, "max_artifacts must be greater than or equal to 0"
|
||||
elif value <= 0:
|
||||
return False, f"{key} must be greater than 0"
|
||||
|
||||
return True, None
|
||||
|
||||
# -- internal helpers -------------------------------------------------
|
||||
|
||||
def _create_client(self):
|
||||
tenki = _get_tenki_module()
|
||||
# Bound control-plane requests (who_am_i, create) so a slow or
|
||||
# unreachable API cannot block initialize()/create_instance() forever.
|
||||
kwargs: dict[str, Any] = {"auth_token": self.api_key, "timeout": float(self.timeout)}
|
||||
if self.base_url:
|
||||
kwargs["base_url"] = self.base_url
|
||||
return tenki.Client(**kwargs)
|
||||
|
||||
def _assert_connectivity(self) -> None:
|
||||
client = self._client or self._create_client()
|
||||
errors = self._tenki_errors()
|
||||
try:
|
||||
client.who_am_i()
|
||||
except errors.UnauthorizedError as exc:
|
||||
raise SandboxProviderConfigError("Tenki authentication failed: check the API key.") from exc
|
||||
except Exception as exc:
|
||||
raise SandboxProviderConfigError(f"Failed to reach Tenki API: {exc}") from exc
|
||||
|
||||
def _prepare_script(self, sandbox, remote_work_dir: str, language: str, code: str, args_json: str) -> tuple[str, list[str]]:
|
||||
if language == "python":
|
||||
script_name = "main.py"
|
||||
script_content = build_python_wrapper(code, args_json)
|
||||
executable = "python3"
|
||||
elif language in {"javascript", "nodejs"}:
|
||||
script_name = "main.js"
|
||||
script_content = build_javascript_wrapper(code, args_json)
|
||||
executable = "node"
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported language for Tenki provider: {language}")
|
||||
|
||||
script_path = posixpath.join(remote_work_dir, script_name)
|
||||
sandbox.fs.write_text(script_path, script_content)
|
||||
return script_path, [executable, script_path]
|
||||
|
||||
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"Tenki execution output exceeded {self.max_output_bytes} bytes.")
|
||||
|
||||
def _collect_artifacts(self, sandbox, artifacts_dir: str) -> list[dict[str, Any]]:
|
||||
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:
|
||||
if depth > MAX_ARTIFACT_DEPTH:
|
||||
raise RuntimeError(f"Artifact directory nesting exceeds {MAX_ARTIFACT_DEPTH} levels: {relative_dir}")
|
||||
|
||||
errors = self._tenki_errors()
|
||||
try:
|
||||
entries = sandbox.fs.list(current_dir)
|
||||
except errors.FileNotFoundError:
|
||||
return
|
||||
except FileNotFoundError:
|
||||
return
|
||||
|
||||
# fs.list returns each entry's basename in `.path`, not an absolute
|
||||
# path, so join it onto the directory being listed.
|
||||
for entry in sorted(entries, key=lambda item: item.path):
|
||||
name = posixpath.basename(entry.path)
|
||||
remote_path = posixpath.join(current_dir, name)
|
||||
relative_path = posixpath.join(relative_dir, name) if relative_dir else name
|
||||
|
||||
# Reject symlinks. `is_symlink` is not populated by every SDK
|
||||
# release, so also inspect the stat mode bits as the reliable check.
|
||||
if getattr(entry, "is_symlink", False) or stat.S_ISLNK(entry.mode or 0):
|
||||
raise RuntimeError(f"Artifact symlinks are not allowed: {relative_path}")
|
||||
if entry.is_dir:
|
||||
self._collect_artifacts_recursive(sandbox, remote_path, relative_path, artifacts, depth + 1)
|
||||
continue
|
||||
|
||||
if len(artifacts) >= self.max_artifacts:
|
||||
raise RuntimeError(f"Tenki execution produced more than {self.max_artifacts} artifacts.")
|
||||
|
||||
size = int(entry.size or 0)
|
||||
if size > self.max_artifact_bytes:
|
||||
raise RuntimeError(f"Artifact exceeds {self.max_artifact_bytes} bytes: {relative_path}")
|
||||
|
||||
ext = os.path.splitext(name)[1].lower()
|
||||
if ext not in ALLOWED_ARTIFACT_EXTENSIONS:
|
||||
raise RuntimeError(f"Unsupported artifact type: {relative_path}")
|
||||
|
||||
content = sandbox.fs.read_bytes(remote_path)
|
||||
artifacts.append(
|
||||
{
|
||||
"name": relative_path,
|
||||
"content_b64": base64.b64encode(content).decode("ascii"),
|
||||
"mime_type": mimetypes.guess_type(name)[0] or "application/octet-stream",
|
||||
"size": size,
|
||||
}
|
||||
)
|
||||
|
||||
def _safe_terminate(self, sandbox) -> None:
|
||||
# Best-effort: max_lifetime reclaims the sandbox if this fails.
|
||||
try:
|
||||
sandbox.terminate()
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to terminate Tenki sandbox, relying on max_lifetime: %s", exc)
|
||||
|
||||
def _tenki_errors(self):
|
||||
tenki = _get_tenki_module()
|
||||
return tenki.errors
|
||||
|
||||
@staticmethod
|
||||
def _normalize_language(language: str) -> str:
|
||||
lang_lower = (language or "python").lower()
|
||||
if lang_lower in {"python", "python3"}:
|
||||
return "python"
|
||||
if lang_lower in {"javascript", "nodejs"}:
|
||||
return "nodejs"
|
||||
return lang_lower
|
||||
|
||||
|
||||
def _get_tenki_module():
|
||||
try:
|
||||
import tenki_sandbox
|
||||
except ImportError as exc:
|
||||
# tenki-sandbox is an optional dependency: it requires protobuf>=6.31,
|
||||
# which conflicts with RAGFlow's pinned gRPC stack, so it is not a core
|
||||
# dependency. Install it into the runtime to enable this provider.
|
||||
raise SandboxProviderConfigError("tenki-sandbox is required for the Tenki sandbox provider. Install it with `pip install tenki-sandbox` (or `uv pip install tenki-sandbox`).") from exc
|
||||
return tenki_sandbox
|
||||
@@ -24,7 +24,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` and `e2b`: Cloud providers.
|
||||
- `aliyun_codeinterpreter`, `e2b`, and `tenki`: Cloud providers.
|
||||
|
||||
<img width="2547" height="1475" alt="admin-sandbox-settings" src="https://github.com/user-attachments/assets/59ab948e-b98a-45a8-9db4-f1afbf6c3685" />
|
||||
|
||||
@@ -38,6 +38,32 @@ Admin > Sandbox Settings after the services are up.
|
||||
- `local`: Runs code as local Python or Node.js subprocesses. Use this only in trusted development environments.
|
||||
- `ssh`: Runs code on a remote machine over SSH.
|
||||
- `aliyun_codeinterpreter` and `e2b`: Cloud-hosted providers that remain available in the admin provider list.
|
||||
- `tenki`: Cloud-hosted provider that runs each execution in a disposable [Tenki](https://tenki.cloud) microVM. See [Tenki](#tenki) below.
|
||||
|
||||
### Tenki
|
||||
|
||||
`tenki` runs each code execution in a fresh Tenki microVM and destroys it afterwards. It is cloud-hosted, so it needs no local sandbox services, gVisor, or Docker base images — only outbound network access and an API key.
|
||||
|
||||
The `tenki-sandbox` SDK is an optional dependency (it requires `protobuf>=6.31`, which differs from RAGFlow's default gRPC stack), so it is not installed by default. Install it into the RAGFlow runtime before selecting this provider:
|
||||
|
||||
```bash
|
||||
pip install tenki-sandbox
|
||||
```
|
||||
|
||||
Configure it in **Admin > Sandbox Settings**:
|
||||
|
||||
- `api_key` (required): Tenki API key. Create one at [app.tenki.cloud](https://app.tenki.cloud) under **API Keys**.
|
||||
- `project_id` (required): the Tenki project that sandboxes are created under.
|
||||
- `base_url` (optional): override the Tenki API endpoint.
|
||||
- `image` (optional): sandbox base image. Leave empty to use the Tenki default image, which includes `python3` and `node`.
|
||||
- `allow_outbound` (optional, security-relevant): whether the sandbox may make outbound network connections. Defaults to `false` so sandboxed code has no network access; set it to `true` when code needs the network (for example, to install packages).
|
||||
- `timeout`, `max_lifetime`, `cpu_cores`, `memory_mb`, `disk_size_gb`, and the output/artifact limits have sensible defaults and can be tuned in the same page.
|
||||
|
||||
Notes:
|
||||
|
||||
- Supported languages are Python and JavaScript.
|
||||
- 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.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
4
go.mod
4
go.mod
@@ -5,6 +5,7 @@ go 1.26.4
|
||||
require (
|
||||
cloud.google.com/go/storage v1.63.0
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/LuxorLabs/tenki-sdk-go/sandbox v0.5.2
|
||||
github.com/alibabacloud-go/agentrun-20250910/v5 v5.8.4
|
||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.2.1
|
||||
github.com/alicebob/miniredis/v2 v2.38.0
|
||||
@@ -70,6 +71,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.21.0 // indirect
|
||||
@@ -77,7 +79,7 @@ require (
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/iam v1.11.0 // indirect
|
||||
cloud.google.com/go/monitoring v1.29.0 // indirect
|
||||
connectrpc.com/connect v1.19.2 // indirect
|
||||
connectrpc.com/connect v1.20.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect
|
||||
|
||||
8
go.sum
8
go.sum
@@ -1,3 +1,5 @@
|
||||
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg=
|
||||
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
|
||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
@@ -21,8 +23,8 @@ cloud.google.com/go/storage v1.63.0 h1:hvXF2xfg9I32bjujggxgkEZn/Ej6sJ9pieFgeueBL
|
||||
cloud.google.com/go/storage v1.63.0/go.mod h1:tirWVptrFNo5GEX2DQ47JooF7yaweJdAJ1hYAVMvKzE=
|
||||
cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E=
|
||||
cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0=
|
||||
connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo=
|
||||
connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
|
||||
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
|
||||
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8=
|
||||
@@ -37,6 +39,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk=
|
||||
github.com/LuxorLabs/tenki-sdk-go/sandbox v0.5.2 h1:HN2UwfNhHYCT7qmJJ5KKf+erRoH5RTfGb7N8dEbE9FQ=
|
||||
github.com/LuxorLabs/tenki-sdk-go/sandbox v0.5.2/go.mod h1:rBypesed6hSrj+OgpJwyMVBF76PaQ1iOqc2RGUL4RmI=
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
|
||||
github.com/alibabacloud-go/agentrun-20250910/v5 v5.8.4 h1:hiAsm9pz6aICOPLI1FC54vga10xwd/XxfNj06ow5jVM=
|
||||
|
||||
@@ -299,8 +299,10 @@ func buildProvider(t ProviderType) (SandboxProvider, error) {
|
||||
return newLocalProviderFromEnv(), nil
|
||||
case ProviderSSH:
|
||||
return newSSHProviderFromEnv(), nil
|
||||
case ProviderTenki:
|
||||
return newTenkiProviderFromEnv(), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown provider type %q (known: self_managed, aliyun_codeinterpreter, e2b, local, ssh)", t)
|
||||
return nil, fmt.Errorf("unknown provider type %q (known: self_managed, aliyun_codeinterpreter, e2b, local, ssh, tenki)", t)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,7 +322,9 @@ func buildProviderFromConfig(t ProviderType, cfg map[string]any) (SandboxProvide
|
||||
return newLocalProviderFromConfig(cfg), nil
|
||||
case ProviderSSH:
|
||||
return newSSHProviderFromConfig(cfg), nil
|
||||
case ProviderTenki:
|
||||
return newTenkiProviderFromConfig(cfg), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown provider type %q (known: self_managed, aliyun_codeinterpreter, e2b, local, ssh)", t)
|
||||
return nil, fmt.Errorf("unknown provider type %q (known: self_managed, aliyun_codeinterpreter, e2b, local, ssh, tenki)", t)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,10 @@ const (
|
||||
// ProviderSSH runs the user's code on a remote host via
|
||||
// SSH. Matches Python's SSHProvider.
|
||||
ProviderSSH ProviderType = "ssh"
|
||||
|
||||
// ProviderTenki runs each execution in a disposable Tenki
|
||||
// microVM (TenkiCloud Go SDK). Matches Python's TenkiProvider.
|
||||
ProviderTenki ProviderType = "tenki"
|
||||
)
|
||||
|
||||
// ErrE2BProviderNotImplemented is returned when an operator configures
|
||||
|
||||
311
internal/agent/sandbox/tenki.go
Normal file
311
internal/agent/sandbox/tenki.go
Normal file
@@ -0,0 +1,311 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
// tenki.go is the Go port of
|
||||
// `agent/sandbox/providers/tenki.py::TenkiProvider`. It runs each
|
||||
// CodeExec call in a disposable Tenki microVM: CreateInstance
|
||||
// provisions a fresh sandbox, ExecuteCode runs `python3 -c <wrapped>`
|
||||
// or `node -e <wrapped>` inside it, and DestroyInstance terminates it.
|
||||
// The provider uses only Tenki's create/exec/destroy operations; it
|
||||
// does not use volumes or snapshots.
|
||||
//
|
||||
// The code-wrapping protocol is shared with SelfManaged, Aliyun and
|
||||
// e2b, so the `__RAGFLOW_RESULT__:` marker extraction works uniformly.
|
||||
// Like the Go e2b provider, this port does not collect run artifacts
|
||||
// (artifact collection lives only in the local/ssh providers).
|
||||
//
|
||||
// SDK: github.com/LuxorLabs/tenki-sdk-go/sandbox (MIT). The auth
|
||||
// token and API URL are read from env by Initialize; image and
|
||||
// tunables come from the admin-panel config map. Sandbox scope is
|
||||
// inferred from the API key by the SDK.
|
||||
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"ragflow/internal/common"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tenkisdk "github.com/LuxorLabs/tenki-sdk-go/sandbox"
|
||||
)
|
||||
|
||||
// tenkiDefaultSandboxTimeout bounds a single CodeExec call. A fresh
|
||||
// sandbox is provisioned per CreateInstance and destroyed on
|
||||
// DestroyInstance, so the lifetime need only outlast one execution.
|
||||
const tenkiDefaultSandboxTimeout = 5 * time.Minute
|
||||
|
||||
// TenkiProvider is the Go port of the Python TenkiProvider.
|
||||
type TenkiProvider struct {
|
||||
client *tenkisdk.Client
|
||||
apiKey string
|
||||
apiURL string
|
||||
image string
|
||||
allowOutbound bool
|
||||
sandboxTimeout time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
initialized bool
|
||||
}
|
||||
|
||||
// newTenkiProviderFromEnv reads TENKI_* env vars and returns a
|
||||
// provider ready for Initialize.
|
||||
func newTenkiProviderFromEnv() *TenkiProvider {
|
||||
return newTenkiProviderFromConfig(tenkiConfigFromEnv())
|
||||
}
|
||||
|
||||
// tenkiConfigFromEnv builds a config map from the TENKI_* env vars,
|
||||
// mirroring the admin-panel settings JSON shape.
|
||||
func tenkiConfigFromEnv() map[string]any {
|
||||
return map[string]any{
|
||||
"API_KEY": common.GetEnv(common.EnvTenkiApiKey),
|
||||
"API_URL": common.GetEnv(common.EnvTenkiAPIURL),
|
||||
"IMAGE": common.GetEnv(common.EnvTenkiImage),
|
||||
"TIMEOUT": common.GetEnv(common.EnvTenkiTimeout),
|
||||
"ALLOW_OUTBOUND": common.GetEnv(common.EnvTenkiAllowOutbound),
|
||||
}
|
||||
}
|
||||
|
||||
// newTenkiProviderFromConfig builds the provider from a JSON config
|
||||
// map (admin-panel settings or the env-backed map above).
|
||||
func newTenkiProviderFromConfig(cfg map[string]any) *TenkiProvider {
|
||||
p := &TenkiProvider{
|
||||
apiKey: configString(cfg, "API_KEY"),
|
||||
apiURL: configString(cfg, "API_URL"),
|
||||
image: configString(cfg, "IMAGE"),
|
||||
// Outbound network is opt-in: sandboxed code has no egress
|
||||
// unless ALLOW_OUTBOUND is explicitly "true". This matches
|
||||
// the self_managed sandbox, which treats network access as an
|
||||
// unauthorized-access event by default.
|
||||
allowOutbound: configString(cfg, "ALLOW_OUTBOUND") == "true",
|
||||
}
|
||||
timeoutSec := configInt(cfg, "TIMEOUT", int(tenkiDefaultSandboxTimeout.Seconds()))
|
||||
if timeoutSec > 0 {
|
||||
p.sandboxTimeout = time.Duration(timeoutSec) * time.Second
|
||||
} else {
|
||||
p.sandboxTimeout = tenkiDefaultSandboxTimeout
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// ProviderType returns ProviderTenki.
|
||||
func (p *TenkiProvider) ProviderType() ProviderType { return ProviderTenki }
|
||||
|
||||
// Initialize builds the Tenki SDK client. The auth token comes from
|
||||
// the admin config or TENKI_API_KEY; we check explicitly so the
|
||||
// manager does not register a broken provider.
|
||||
func (p *TenkiProvider) Initialize(ctx context.Context) error {
|
||||
apiKey := p.apiKey
|
||||
if apiKey == "" {
|
||||
apiKey = common.GetEnv(common.EnvTenkiApiKey)
|
||||
}
|
||||
if apiKey == "" {
|
||||
return errors.New("tenki: API key is required (set it in Admin > Sandbox Settings or TENKI_API_KEY)")
|
||||
}
|
||||
apiURL := p.apiURL
|
||||
if apiURL == "" {
|
||||
apiURL = common.GetEnv(common.EnvTenkiAPIURL)
|
||||
}
|
||||
opts := []tenkisdk.Option{tenkisdk.WithAuthToken(apiKey)}
|
||||
if apiURL != "" {
|
||||
opts = append(opts, tenkisdk.WithBaseURL(apiURL))
|
||||
}
|
||||
c, err := tenkisdk.New(opts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tenki: build client: %w", err)
|
||||
}
|
||||
p.client = c
|
||||
p.mu.Lock()
|
||||
p.initialized = true
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SupportedLanguages returns the languages the default Tenki image can
|
||||
// run. The default image ships with python3 and node.
|
||||
func (p *TenkiProvider) SupportedLanguages() []string {
|
||||
return []string{"python", "javascript"}
|
||||
}
|
||||
|
||||
// CreateInstance provisions a fresh Tenki sandbox. As with the e2b
|
||||
// provider, the template argument is treated as the language hint; the
|
||||
// actual base image comes from the configured image (empty = Tenki's
|
||||
// default image). InstanceID is the Tenki session id.
|
||||
func (p *TenkiProvider) CreateInstance(ctx context.Context, template string) (*SandboxInstance, error) {
|
||||
if !p.isInitialized() {
|
||||
return nil, fmt.Errorf("tenki: provider not initialized")
|
||||
}
|
||||
lang := normalizeLanguage(template)
|
||||
if lang == "" {
|
||||
return nil, fmt.Errorf("tenki: unsupported language %q", template)
|
||||
}
|
||||
opts := []tenkisdk.CreateOption{
|
||||
tenkisdk.WithAllowOutbound(p.allowOutbound),
|
||||
tenkisdk.WithMaxDuration(p.sandboxTimeout),
|
||||
tenkisdk.WithWaitTimeout(p.sandboxTimeout),
|
||||
}
|
||||
if p.image != "" {
|
||||
opts = append(opts, tenkisdk.WithImage(p.image))
|
||||
}
|
||||
sess, err := p.client.Create(ctx, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tenki: Create: %w", err)
|
||||
}
|
||||
return &SandboxInstance{
|
||||
InstanceID: sess.ID,
|
||||
Provider: ProviderTenki,
|
||||
Status: "running",
|
||||
Metadata: map[string]any{
|
||||
"language": lang,
|
||||
"image": p.image,
|
||||
"sandbox_timeout": p.sandboxTimeout.String(),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ExecuteCode runs the user's code inside the sandbox via
|
||||
// `python3 -c <wrapped>` (Python) or `node -e <wrapped>` (JS). The
|
||||
// wrapped code carries the `__RAGFLOW_RESULT__:` marker so the
|
||||
// structured main() return value comes back as before.
|
||||
func (p *TenkiProvider) ExecuteCode(
|
||||
ctx context.Context,
|
||||
inst *SandboxInstance,
|
||||
code, language string,
|
||||
timeoutSec int,
|
||||
args map[string]any,
|
||||
) (*ExecutionResult, error) {
|
||||
if !p.isInitialized() {
|
||||
return nil, fmt.Errorf("tenki: provider not initialized")
|
||||
}
|
||||
if inst == nil || inst.InstanceID == "" {
|
||||
return nil, fmt.Errorf("tenki: instance id required")
|
||||
}
|
||||
lang := normalizeLanguage(language)
|
||||
if lang == "" {
|
||||
return nil, fmt.Errorf("tenki: unsupported language %q", language)
|
||||
}
|
||||
timeout, err := validateTimeout(timeoutSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if timeout == 0 {
|
||||
timeout = int(p.sandboxTimeout.Seconds())
|
||||
}
|
||||
|
||||
argsJSON, err := argsToJSON(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var wrapped, cmd string
|
||||
var runArgs []string
|
||||
if lang == "python" {
|
||||
cmd = "python3"
|
||||
wrapped = BuildPythonWrapper(code, argsJSON)
|
||||
runArgs = []string{"-c", wrapped}
|
||||
} else {
|
||||
cmd = "node"
|
||||
wrapped = BuildJavaScriptWrapper(code, argsJSON)
|
||||
runArgs = []string{"-e", wrapped}
|
||||
}
|
||||
|
||||
// Re-obtain a handle to the sandbox created in CreateInstance and
|
||||
// wait for its data plane to be ready before exec.
|
||||
sess, err := p.client.Session(inst.InstanceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tenki: Session(%s): %w", inst.InstanceID, err)
|
||||
}
|
||||
if err := sess.WaitReady(ctx, p.sandboxTimeout); err != nil {
|
||||
return nil, fmt.Errorf("tenki: WaitReady(%s): %w", inst.InstanceID, err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
res, err := sess.Exec(ctx, cmd,
|
||||
tenkisdk.WithArgs(runArgs...),
|
||||
tenkisdk.WithTimeout(time.Duration(timeout)*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
// A non-zero exit is reported as a *Result, not an error;
|
||||
// a nil result here means a transport, timeout or session
|
||||
// error that we cannot map to stdout/stderr. Do not include
|
||||
// runArgs — they embed the user's code and arguments.
|
||||
return nil, fmt.Errorf("tenki: exec %s: %w", cmd, err)
|
||||
}
|
||||
return buildTenkiExecutionResult(res, lang, start), nil
|
||||
}
|
||||
|
||||
// buildTenkiExecutionResult maps the Tenki Result to our
|
||||
// sandbox.ExecutionResult. Stdout is scanned (untrimmed) for the
|
||||
// `__RAGFLOW_RESULT__:` marker so the model gets the structured
|
||||
// main() return value.
|
||||
func buildTenkiExecutionResult(r *tenkisdk.Result, lang string, start time.Time) *ExecutionResult {
|
||||
stdout, structured := ExtractStructuredResult(string(r.Stdout))
|
||||
return &ExecutionResult{
|
||||
Stdout: stdout,
|
||||
Stderr: string(r.Stderr),
|
||||
ExitCode: int(r.ExitCode),
|
||||
ExecutionTime: time.Since(start).Seconds(),
|
||||
Metadata: map[string]any{
|
||||
"language": lang,
|
||||
"structured_result": structured,
|
||||
"tenki_status": string(r.Status),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DestroyInstance terminates the sandbox. A session that is already
|
||||
// gone (not found, terminated, or expired past its max duration) is
|
||||
// treated as success so the call is idempotent.
|
||||
func (p *TenkiProvider) DestroyInstance(ctx context.Context, inst *SandboxInstance) error {
|
||||
if !p.isInitialized() {
|
||||
return fmt.Errorf("tenki: provider not initialized")
|
||||
}
|
||||
if inst == nil || inst.InstanceID == "" {
|
||||
return fmt.Errorf("tenki: instance id required")
|
||||
}
|
||||
sess, err := p.client.Session(inst.InstanceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tenki: Session(%s): %w", inst.InstanceID, err)
|
||||
}
|
||||
if err := sess.Close(ctx); err != nil {
|
||||
if errors.Is(err, tenkisdk.ErrSessionNotFound) ||
|
||||
errors.Is(err, tenkisdk.ErrSessionTerminated) ||
|
||||
errors.Is(err, tenkisdk.ErrSessionExpired) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("tenki: Close(%s): %w", inst.InstanceID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HealthCheck probes the Tenki control plane. There is no dedicated
|
||||
// ping endpoint, so a successful WhoAmI is our probe.
|
||||
func (p *TenkiProvider) HealthCheck(ctx context.Context) error {
|
||||
if !p.isInitialized() {
|
||||
return errors.New("tenki: provider not initialized")
|
||||
}
|
||||
if _, err := p.client.WhoAmI(ctx); err != nil {
|
||||
return fmt.Errorf("tenki: WhoAmI: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *TenkiProvider) isInitialized() bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.initialized
|
||||
}
|
||||
292
internal/agent/sandbox/tenki_test.go
Normal file
292
internal/agent/sandbox/tenki_test.go
Normal file
@@ -0,0 +1,292 @@
|
||||
//
|
||||
// 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"
|
||||
"ragflow/internal/common"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tenkisdk "github.com/LuxorLabs/tenki-sdk-go/sandbox"
|
||||
)
|
||||
|
||||
func TestTenkiProvider_ProviderTypeAndLanguages(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newTenkiProviderFromEnv()
|
||||
if p.ProviderType() != ProviderTenki {
|
||||
t.Errorf("ProviderType = %q, want %q", p.ProviderType(), ProviderTenki)
|
||||
}
|
||||
langs := p.SupportedLanguages()
|
||||
want := map[string]bool{"python": true, "javascript": true}
|
||||
got := map[string]bool{}
|
||||
for _, l := range langs {
|
||||
if !want[l] {
|
||||
t.Errorf("unexpected language: %q", l)
|
||||
}
|
||||
got[l] = true
|
||||
}
|
||||
for l := range want {
|
||||
if !got[l] {
|
||||
t.Errorf("missing required language: %q", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTenkiProvider_Defaults exercises the env-var defaults. We clear
|
||||
// TENKI_* so the test is independent of the host environment. Cannot
|
||||
// use t.Parallel with t.Setenv.
|
||||
func TestTenkiProvider_Defaults(t *testing.T) {
|
||||
for _, k := range []string{"TENKI_IMAGE", "TENKI_TIMEOUT", "TENKI_ALLOW_OUTBOUND"} {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
p := newTenkiProviderFromEnv()
|
||||
if p.image != "" {
|
||||
t.Errorf("image = %q, want empty (SDK default image)", p.image)
|
||||
}
|
||||
if p.sandboxTimeout != tenkiDefaultSandboxTimeout {
|
||||
t.Errorf("sandboxTimeout = %v, want %v", p.sandboxTimeout, tenkiDefaultSandboxTimeout)
|
||||
}
|
||||
if p.allowOutbound {
|
||||
t.Errorf("allowOutbound = true, want false by default (network is opt-in)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenkiProvider_EnvOverride(t *testing.T) {
|
||||
t.Setenv("TENKI_IMAGE", "custom-image")
|
||||
t.Setenv("TENKI_TIMEOUT", "120")
|
||||
t.Setenv("TENKI_ALLOW_OUTBOUND", "true")
|
||||
p := newTenkiProviderFromEnv()
|
||||
if p.image != "custom-image" {
|
||||
t.Errorf("image = %q, want %q", p.image, "custom-image")
|
||||
}
|
||||
if p.sandboxTimeout != 120*time.Second {
|
||||
t.Errorf("sandboxTimeout = %v, want 120s", p.sandboxTimeout)
|
||||
}
|
||||
if !p.allowOutbound {
|
||||
t.Errorf("allowOutbound = false, want true when TENKI_ALLOW_OUTBOUND=true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTenkiProvider_Initialize_MissingCreds verifies the provider
|
||||
// refuses to initialize when TENKI_API_KEY is unset.
|
||||
func TestTenkiProvider_Initialize_MissingCreds(t *testing.T) {
|
||||
t.Setenv("TENKI_API_KEY", "")
|
||||
p := newTenkiProviderFromEnv()
|
||||
err := p.Initialize(context.Background())
|
||||
if err == nil {
|
||||
t.Fatalf("Initialize with no creds: got nil error, want one")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "TENKI_API_KEY") {
|
||||
t.Errorf("err = %v, want to mention TENKI_API_KEY", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTenkiProvider_AllOps_BeforeInit verifies the "not initialized"
|
||||
// guard is in place for every operational method.
|
||||
func TestTenkiProvider_AllOps_BeforeInit(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newTenkiProviderFromEnv()
|
||||
// Do NOT call Initialize.
|
||||
|
||||
inst := &SandboxInstance{InstanceID: "x", Provider: ProviderTenki}
|
||||
if _, err := p.CreateInstance(context.Background(), "python"); err == nil {
|
||||
t.Errorf("CreateInstance before init: got nil error, want one")
|
||||
}
|
||||
if _, err := p.ExecuteCode(context.Background(), inst, "x", "python", 5, nil); err == nil {
|
||||
t.Errorf("ExecuteCode before init: got nil error, want one")
|
||||
}
|
||||
if err := p.DestroyInstance(context.Background(), inst); err == nil {
|
||||
t.Errorf("DestroyInstance before init: got nil error, want one")
|
||||
}
|
||||
if err := p.HealthCheck(context.Background()); err == nil {
|
||||
t.Errorf("HealthCheck before init: got nil error, want one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenkiProvider_ExecuteCode_RejectsBadInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newTenkiProviderFromEnv()
|
||||
// Force "initialized" without building the SDK client so we can
|
||||
// test the input-validation paths without a network call.
|
||||
p.initialized = true
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
fn func() error
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty instance id",
|
||||
fn: func() error {
|
||||
_, err := p.ExecuteCode(context.Background(),
|
||||
&SandboxInstance{InstanceID: ""}, "x", "python", 5, nil)
|
||||
return err
|
||||
},
|
||||
want: "instance id",
|
||||
},
|
||||
{
|
||||
name: "nil instance",
|
||||
fn: func() error {
|
||||
_, err := p.ExecuteCode(context.Background(),
|
||||
nil, "x", "python", 5, nil)
|
||||
return err
|
||||
},
|
||||
want: "instance id",
|
||||
},
|
||||
{
|
||||
name: "unsupported language",
|
||||
fn: func() error {
|
||||
_, err := p.ExecuteCode(context.Background(),
|
||||
&SandboxInstance{InstanceID: "x"}, "x", "ruby", 5, nil)
|
||||
return err
|
||||
},
|
||||
want: "unsupported language",
|
||||
},
|
||||
{
|
||||
name: "timeout too small",
|
||||
fn: func() error {
|
||||
_, err := p.ExecuteCode(context.Background(),
|
||||
&SandboxInstance{InstanceID: "x"}, "x", "python", 0, nil)
|
||||
return err
|
||||
},
|
||||
want: "timeout",
|
||||
},
|
||||
{
|
||||
name: "timeout too large",
|
||||
fn: func() error {
|
||||
_, err := p.ExecuteCode(context.Background(),
|
||||
&SandboxInstance{InstanceID: "x"}, "x", "python", 1000, nil)
|
||||
return err
|
||||
},
|
||||
want: "timeout",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.fn()
|
||||
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 TestTenkiProvider_CreateInstance_UnsupportedLanguage(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newTenkiProviderFromEnv()
|
||||
p.initialized = true
|
||||
if _, err := p.CreateInstance(context.Background(), "ruby"); err == nil {
|
||||
t.Errorf("CreateInstance(ruby): got nil error, want one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenkiProvider_DestroyInstance_EmptyID(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newTenkiProviderFromEnv()
|
||||
p.initialized = true
|
||||
if err := p.DestroyInstance(context.Background(), &SandboxInstance{InstanceID: ""}); err == nil {
|
||||
t.Errorf("DestroyInstance(empty id): got nil error, want one")
|
||||
}
|
||||
if err := p.DestroyInstance(context.Background(), nil); err == nil {
|
||||
t.Errorf("DestroyInstance(nil): got nil error, want one")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTenkiProvider_BuildTenkiExecutionResult unit-tests the
|
||||
// result-mapping helper, including the marker-extraction path, using
|
||||
// a hand-built SDK Result (no network).
|
||||
func TestTenkiProvider_BuildTenkiExecutionResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := &tenkisdk.Result{
|
||||
Stdout: []byte("hello\n"),
|
||||
Stderr: []byte(""),
|
||||
ExitCode: 0,
|
||||
Status: tenkisdk.CommandStatusSucceeded,
|
||||
}
|
||||
res := buildTenkiExecutionResult(r, "python", time.Now())
|
||||
if res == nil {
|
||||
t.Fatalf("buildTenkiExecutionResult returned nil")
|
||||
}
|
||||
if res.ExitCode != 0 {
|
||||
t.Errorf("ExitCode = %d, want 0", res.ExitCode)
|
||||
}
|
||||
if res.Stdout != "hello\n" {
|
||||
t.Errorf("Stdout = %q, want %q", res.Stdout, "hello\n")
|
||||
}
|
||||
if v, _ := res.Metadata["language"].(string); v != "python" {
|
||||
t.Errorf("Metadata[language] = %v, want python", res.Metadata["language"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestTenkiProvider_FullE2E_SkipWithoutKey is the integration test
|
||||
// path. The body is skipped unless TENKI_API_KEY is set, but the test
|
||||
// always runs (so missing-secrets shows up in CI logs). When enabled,
|
||||
// it creates a real sandbox, runs Python, and destroys it.
|
||||
func TestTenkiProvider_FullE2E_SkipWithoutKey(t *testing.T) {
|
||||
apiKey := common.GetEnv(common.EnvTenkiApiKey)
|
||||
if apiKey == "" {
|
||||
t.Skip("TENKI_API_KEY not set — skipping full E2E test (real network call)")
|
||||
}
|
||||
p := newTenkiProviderFromEnv()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
if err := p.Initialize(ctx); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
inst, err := p.CreateInstance(ctx, "python")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateInstance: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cleanupCancel()
|
||||
if err := p.DestroyInstance(cleanupCtx, inst); err != nil {
|
||||
t.Errorf("DestroyInstance: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
result, err := p.ExecuteCode(ctx, inst, "def main(): return 1+1", "python", 30, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteCode: %v", err)
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
t.Errorf("ExitCode = %d, want 0; stderr=%q", result.ExitCode, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTenkiProvider_ProviderType_StaysDistinct ensures the tenki
|
||||
// provider does not collide on the wire with the other providers.
|
||||
func TestTenkiProvider_ProviderType_StaysDistinct(t *testing.T) {
|
||||
t.Parallel()
|
||||
seen := map[ProviderType]bool{}
|
||||
for _, p := range []SandboxProvider{
|
||||
newSelfManagedProviderFromEnv(),
|
||||
newAliyunProviderFromEnv(),
|
||||
newE2BProviderFromEnv(),
|
||||
newTenkiProviderFromEnv(),
|
||||
} {
|
||||
if seen[p.ProviderType()] {
|
||||
t.Errorf("provider type %q seen twice", p.ProviderType())
|
||||
}
|
||||
seen[p.ProviderType()] = true
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,11 @@ const (
|
||||
EnvE2BApiKey = "E2B_API_KEY"
|
||||
EnvE2BAccessToken = "E2B_ACCESS_TOKEN"
|
||||
EnvE2BDomain = "E2B_DOMAIN"
|
||||
EnvTenkiApiKey = "TENKI_API_KEY"
|
||||
EnvTenkiAPIURL = "TENKI_API_URL"
|
||||
EnvTenkiImage = "TENKI_IMAGE"
|
||||
EnvTenkiTimeout = "TENKI_TIMEOUT"
|
||||
EnvTenkiAllowOutbound = "TENKI_ALLOW_OUTBOUND"
|
||||
EnvLocalPythonBin = "LOCAL_PYTHON_BIN"
|
||||
EnvLocalNodeBin = "LOCAL_NODE_BIN"
|
||||
EnvLocalWorkDir = "LOCAL_WORK_DIR"
|
||||
|
||||
428
test/unit_test/agent/sandbox/test_tenki_provider.py
Normal file
428
test/unit_test/agent/sandbox/test_tenki_provider.py
Normal file
@@ -0,0 +1,428 @@
|
||||
import base64
|
||||
import posixpath
|
||||
import stat
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.sandbox.providers.base import SandboxProviderConfigError
|
||||
from agent.sandbox.providers.tenki import TenkiProvider
|
||||
from agent.sandbox.result_protocol import RESULT_MARKER_PREFIX
|
||||
|
||||
pytestmark = pytest.mark.p3
|
||||
|
||||
|
||||
class _FakeErrors:
|
||||
"""Stand-in for tenki_sandbox.errors so tests need not install the SDK."""
|
||||
|
||||
class QuotaExceededError(Exception):
|
||||
pass
|
||||
|
||||
class RateLimitedError(Exception):
|
||||
pass
|
||||
|
||||
class UnauthorizedError(Exception):
|
||||
pass
|
||||
|
||||
class CommandTimeoutError(Exception):
|
||||
pass
|
||||
|
||||
class PrimitiveTimeoutError(Exception):
|
||||
pass
|
||||
|
||||
class SessionTerminatedError(Exception):
|
||||
pass
|
||||
|
||||
class SessionNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
class FileNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _FakeFS:
|
||||
def __init__(self):
|
||||
self.files: dict[str, bytes] = {}
|
||||
|
||||
def write_text(self, path: str, content: str):
|
||||
self.files[path] = content.encode("utf-8")
|
||||
|
||||
def read_bytes(self, path: str) -> bytes:
|
||||
return self.files[path]
|
||||
|
||||
def list(self, path: str, *, include_hidden: bool = False):
|
||||
# Mirror the real SDK: entry.path is the basename within `path`,
|
||||
# not an absolute path.
|
||||
prefix = path.rstrip("/") + "/"
|
||||
entries = []
|
||||
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("/")
|
||||
if tail: # nested -> surface the intermediate directory once
|
||||
if head not in seen_dirs:
|
||||
seen_dirs.add(head)
|
||||
entries.append(SimpleNamespace(path=head, size=4096, mode=0o40755, is_dir=True, is_symlink=False, symlink_target=""))
|
||||
continue
|
||||
entries.append(
|
||||
SimpleNamespace(
|
||||
path=head,
|
||||
size=len(payload),
|
||||
mode=0o100644,
|
||||
is_dir=False,
|
||||
is_symlink=False,
|
||||
symlink_target="",
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
class _FakeCommandResult:
|
||||
def __init__(self, exit_code: int, stdout: str = "", stderr: str = ""):
|
||||
self.exit_code = exit_code
|
||||
self.stdout_text = stdout
|
||||
self.stderr_text = stderr
|
||||
self.stdout = stdout.encode("utf-8")
|
||||
self.stderr = stderr.encode("utf-8")
|
||||
|
||||
|
||||
class _FakeSandbox:
|
||||
def __init__(self, run_handler=None):
|
||||
self.id = "sbx-fake-1"
|
||||
self.state = "RUNNING"
|
||||
self.fs = _FakeFS()
|
||||
self.terminated = False
|
||||
self._run_handler = run_handler
|
||||
self.exec_calls: list[tuple] = []
|
||||
|
||||
def exec(self, *argv, cwd=None, timeout=None, env=None, input=None, check=False, privileged=False):
|
||||
self.exec_calls.append((argv, cwd, timeout))
|
||||
if argv and argv[0] == "mkdir":
|
||||
return _FakeCommandResult(0)
|
||||
if self._run_handler is not None:
|
||||
return self._run_handler(self, argv, cwd)
|
||||
return _FakeCommandResult(0)
|
||||
|
||||
def terminate(self):
|
||||
self.terminated = True
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, sandbox: _FakeSandbox):
|
||||
self._sandbox = sandbox
|
||||
self.create_kwargs = None
|
||||
|
||||
def who_am_i(self):
|
||||
return SimpleNamespace(owner_type="WORKSPACE")
|
||||
|
||||
def create(self, **kwargs):
|
||||
self.create_kwargs = kwargs
|
||||
return self._sandbox
|
||||
|
||||
|
||||
def _build_provider(sandbox: _FakeSandbox, monkeypatch) -> tuple[TenkiProvider, _FakeClient]:
|
||||
provider = TenkiProvider()
|
||||
provider.api_key = "tk_test"
|
||||
provider.project_id = "proj-1"
|
||||
provider.timeout = 30
|
||||
provider.max_output_bytes = 1024 * 1024
|
||||
provider.max_artifacts = 20
|
||||
provider.max_artifact_bytes = 1024 * 1024
|
||||
provider._initialized = True
|
||||
client = _FakeClient(sandbox)
|
||||
provider._client = client
|
||||
monkeypatch.setattr(provider, "_tenki_errors", lambda: _FakeErrors)
|
||||
return provider, client
|
||||
|
||||
|
||||
def test_tenki_provider_executes_python_main_and_collects_artifacts(monkeypatch):
|
||||
def run_handler(sandbox, argv, cwd):
|
||||
# Simulate the script run: emit a structured result + write an artifact.
|
||||
sandbox.fs.files[posixpath.join(cwd, "artifacts", "chart.png")] = b"PNGDATA"
|
||||
payload = base64.b64encode(b'{"present":true,"value":{"message":"hello tenki"},"type":"json"}').decode("ascii")
|
||||
return _FakeCommandResult(0, stdout=f"debug line\n{RESULT_MARKER_PREFIX}{payload}\n")
|
||||
|
||||
sandbox = _FakeSandbox(run_handler)
|
||||
provider, _ = _build_provider(sandbox, monkeypatch)
|
||||
|
||||
instance = provider.create_instance("python")
|
||||
result = provider.execute_code(
|
||||
instance.instance_id,
|
||||
'def main() -> dict:\n return {"message": "hello tenki"}\n',
|
||||
"python",
|
||||
timeout=5,
|
||||
)
|
||||
provider.destroy_instance(instance.instance_id)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout == "debug line\n"
|
||||
assert result.metadata["result_present"] is True
|
||||
assert result.metadata["result_value"] == {"message": "hello tenki"}
|
||||
assert result.metadata["artifacts"] == [
|
||||
{
|
||||
"name": "chart.png",
|
||||
"content_b64": base64.b64encode(b"PNGDATA").decode("ascii"),
|
||||
"mime_type": "image/png",
|
||||
"size": 7,
|
||||
}
|
||||
]
|
||||
assert sandbox.terminated is True
|
||||
|
||||
|
||||
def test_tenki_provider_reports_nonzero_exit(monkeypatch):
|
||||
def run_handler(sandbox, argv, cwd):
|
||||
return _FakeCommandResult(7, stdout="", stderr="boom\n")
|
||||
|
||||
sandbox = _FakeSandbox(run_handler)
|
||||
provider, _ = _build_provider(sandbox, monkeypatch)
|
||||
|
||||
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_tenki_provider_propagates_timeout(monkeypatch):
|
||||
def run_handler(sandbox, argv, cwd):
|
||||
raise _FakeErrors.CommandTimeoutError("timed out")
|
||||
|
||||
sandbox = _FakeSandbox(run_handler)
|
||||
provider, _ = _build_provider(sandbox, monkeypatch)
|
||||
|
||||
instance = provider.create_instance("python")
|
||||
with pytest.raises(TimeoutError, match="timed out after"):
|
||||
provider.execute_code(instance.instance_id, "def main():\n return 1\n", "python", timeout=5)
|
||||
|
||||
|
||||
def test_tenki_provider_create_maps_quota_error(monkeypatch):
|
||||
sandbox = _FakeSandbox()
|
||||
provider, client = _build_provider(sandbox, monkeypatch)
|
||||
|
||||
def _raise_quota(**kwargs):
|
||||
raise _FakeErrors.QuotaExceededError("no credits")
|
||||
|
||||
client.create = _raise_quota
|
||||
with pytest.raises(RuntimeError, match="quota exceeded"):
|
||||
provider.create_instance("python")
|
||||
|
||||
|
||||
def test_tenki_provider_destroy_is_idempotent(monkeypatch):
|
||||
sandbox = _FakeSandbox()
|
||||
provider, _ = _build_provider(sandbox, monkeypatch)
|
||||
# Destroying an unknown instance returns True without error.
|
||||
assert provider.destroy_instance("nonexistent") is True
|
||||
|
||||
|
||||
def test_tenki_provider_create_passes_project_and_outbound(monkeypatch):
|
||||
sandbox = _FakeSandbox()
|
||||
provider, client = _build_provider(sandbox, monkeypatch)
|
||||
provider.image = "my-image"
|
||||
provider.cpu_cores = 4
|
||||
provider.allow_outbound = True
|
||||
|
||||
provider.create_instance("python")
|
||||
|
||||
assert client.create_kwargs["project_id"] == "proj-1"
|
||||
assert client.create_kwargs["allow_outbound"] is True
|
||||
assert client.create_kwargs["image"] == "my-image"
|
||||
assert client.create_kwargs["cpu_cores"] == 4
|
||||
|
||||
|
||||
def test_tenki_provider_config_schema_and_validation():
|
||||
schema = TenkiProvider.get_config_schema()
|
||||
assert schema["api_key"]["required"] is True
|
||||
assert schema["api_key"]["secret"] is True
|
||||
assert schema["project_id"]["required"] is True
|
||||
|
||||
provider = TenkiProvider()
|
||||
ok, _ = provider.validate_config({"api_key": "tk", "project_id": "p", "timeout": 30, "max_lifetime": 3600, "max_output_bytes": 1024, "max_artifacts": 5, "max_artifact_bytes": 1024})
|
||||
assert ok is True
|
||||
bad, msg = provider.validate_config({"api_key": "", "project_id": "p"})
|
||||
assert bad is False
|
||||
assert "API key" in msg
|
||||
|
||||
|
||||
def test_tenki_provider_supported_languages():
|
||||
assert TenkiProvider().get_supported_languages() == ["python", "javascript"]
|
||||
|
||||
|
||||
def test_tenki_provider_initialize_maps_auth_error(monkeypatch):
|
||||
provider = TenkiProvider()
|
||||
monkeypatch.setattr(provider, "_tenki_errors", lambda: _FakeErrors)
|
||||
|
||||
class _UnauthorizedClient:
|
||||
def who_am_i(self):
|
||||
raise _FakeErrors.UnauthorizedError("bad token")
|
||||
|
||||
monkeypatch.setattr(provider, "_create_client", lambda: _UnauthorizedClient())
|
||||
|
||||
with pytest.raises(SandboxProviderConfigError, match="authentication failed"):
|
||||
provider.initialize({"api_key": "tk_bad", "project_id": "proj-1"})
|
||||
|
||||
|
||||
def test_tenki_provider_instances_are_independent(monkeypatch):
|
||||
sandboxes: list[_FakeSandbox] = []
|
||||
|
||||
class _MultiClient(_FakeClient):
|
||||
def create(self, **kwargs):
|
||||
sb = _FakeSandbox()
|
||||
sb.id = f"sbx-{len(sandboxes)}"
|
||||
sandboxes.append(sb)
|
||||
return sb
|
||||
|
||||
provider = TenkiProvider()
|
||||
provider.api_key = "tk_test"
|
||||
provider.project_id = "proj-1"
|
||||
provider.timeout = 30
|
||||
provider._initialized = True
|
||||
provider._client = _MultiClient(None)
|
||||
monkeypatch.setattr(provider, "_tenki_errors", lambda: _FakeErrors)
|
||||
|
||||
a = provider.create_instance("python")
|
||||
b = provider.create_instance("python")
|
||||
|
||||
assert a.instance_id != b.instance_id
|
||||
assert a.metadata["sandbox_id"] != b.metadata["sandbox_id"]
|
||||
|
||||
provider.destroy_instance(a.instance_id)
|
||||
assert sandboxes[0].terminated is True
|
||||
assert sandboxes[1].terminated is False # destroying one leaves the other running
|
||||
|
||||
provider.destroy_instance(b.instance_id)
|
||||
assert sandboxes[1].terminated is True
|
||||
|
||||
|
||||
def test_tenki_provider_executes_javascript(monkeypatch):
|
||||
def run_handler(sandbox, argv, cwd):
|
||||
payload = base64.b64encode(b'{"present":true,"value":42,"type":"json"}').decode("ascii")
|
||||
return _FakeCommandResult(0, stdout=f"{RESULT_MARKER_PREFIX}{payload}\n")
|
||||
|
||||
sandbox = _FakeSandbox(run_handler)
|
||||
provider, _ = _build_provider(sandbox, monkeypatch)
|
||||
|
||||
inst = provider.create_instance("javascript")
|
||||
assert inst.metadata["language"] == "nodejs"
|
||||
|
||||
result = provider.execute_code(inst.instance_id, "function main(args){return 42;}", "javascript", timeout=5)
|
||||
|
||||
# The script is written as main.js and run with node.
|
||||
assert any(k.endswith("main.js") for k in sandbox.fs.files)
|
||||
run_argv = [c[0] for c in sandbox.exec_calls if c[0] and c[0][0] == "node"]
|
||||
assert run_argv, "expected a node invocation"
|
||||
assert result.metadata["result_value"] == 42
|
||||
|
||||
|
||||
def test_tenki_provider_rejects_symlink_artifacts(monkeypatch):
|
||||
provider, _ = _build_provider(_FakeSandbox(), monkeypatch)
|
||||
|
||||
class _SymlinkFS:
|
||||
def list(self, path, *, include_hidden=False):
|
||||
# is_symlink False but mode marks a symlink -> must still be rejected.
|
||||
return [SimpleNamespace(path="evil.json", size=10, mode=stat.S_IFLNK | 0o777, is_dir=False, is_symlink=False, symlink_target="/etc/passwd")]
|
||||
|
||||
fake_sb = SimpleNamespace(fs=_SymlinkFS())
|
||||
with pytest.raises(RuntimeError, match="symlinks are not allowed"):
|
||||
provider._collect_artifacts(fake_sb, "/home/tenki/wk/artifacts")
|
||||
|
||||
|
||||
def test_tenki_provider_enforces_output_size_limit(monkeypatch):
|
||||
def run_handler(sandbox, argv, cwd):
|
||||
return _FakeCommandResult(0, stdout="x" * 5000)
|
||||
|
||||
sandbox = _FakeSandbox(run_handler)
|
||||
provider, _ = _build_provider(sandbox, monkeypatch)
|
||||
provider.max_output_bytes = 100
|
||||
|
||||
inst = provider.create_instance("python")
|
||||
with pytest.raises(RuntimeError, match="output exceeded"):
|
||||
provider.execute_code(inst.instance_id, "def main():\n return 1\n", "python", timeout=5)
|
||||
|
||||
|
||||
def test_tenki_provider_rejects_disallowed_artifact_extension(monkeypatch):
|
||||
def run_handler(sandbox, argv, cwd):
|
||||
sandbox.fs.files[posixpath.join(cwd, "artifacts", "malware.exe")] = b"MZ"
|
||||
return _FakeCommandResult(0, stdout="")
|
||||
|
||||
sandbox = _FakeSandbox(run_handler)
|
||||
provider, _ = _build_provider(sandbox, monkeypatch)
|
||||
|
||||
inst = provider.create_instance("python")
|
||||
with pytest.raises(RuntimeError, match="Unsupported artifact type"):
|
||||
provider.execute_code(inst.instance_id, "def main():\n return 1\n", "python", timeout=5)
|
||||
|
||||
|
||||
def test_tenki_provider_allow_outbound_configurable(monkeypatch):
|
||||
sandbox = _FakeSandbox()
|
||||
provider, client = _build_provider(sandbox, monkeypatch)
|
||||
provider.allow_outbound = False
|
||||
provider.create_instance("python")
|
||||
assert client.create_kwargs["allow_outbound"] is False
|
||||
|
||||
|
||||
def test_tenki_provider_maps_session_terminated(monkeypatch):
|
||||
def run_handler(sandbox, argv, cwd):
|
||||
raise _FakeErrors.SessionTerminatedError("reclaimed")
|
||||
|
||||
sandbox = _FakeSandbox(run_handler)
|
||||
provider, _ = _build_provider(sandbox, monkeypatch)
|
||||
inst = provider.create_instance("python")
|
||||
with pytest.raises(RuntimeError, match="no longer available"):
|
||||
provider.execute_code(inst.instance_id, "def main():\n return 1\n", "python", timeout=5)
|
||||
|
||||
|
||||
def test_tenki_provider_create_maps_rate_limit(monkeypatch):
|
||||
sandbox = _FakeSandbox()
|
||||
provider, client = _build_provider(sandbox, monkeypatch)
|
||||
|
||||
def _raise_rate(**kwargs):
|
||||
raise _FakeErrors.RateLimitedError("slow down")
|
||||
|
||||
client.create = _raise_rate
|
||||
with pytest.raises(RuntimeError, match="rate limited"):
|
||||
provider.create_instance("python")
|
||||
|
||||
|
||||
def test_tenki_provider_create_wraps_unexpected_sdk_error(monkeypatch):
|
||||
sandbox = _FakeSandbox()
|
||||
provider, client = _build_provider(sandbox, monkeypatch)
|
||||
|
||||
def _raise_other(**kwargs):
|
||||
raise ValueError("weird sdk failure")
|
||||
|
||||
client.create = _raise_other
|
||||
with pytest.raises(RuntimeError, match="Failed to create Tenki sandbox"):
|
||||
provider.create_instance("python")
|
||||
|
||||
|
||||
def test_tenki_provider_enforces_max_artifacts(monkeypatch):
|
||||
def run_handler(sandbox, argv, cwd):
|
||||
for i in range(3):
|
||||
sandbox.fs.files[posixpath.join(cwd, "artifacts", f"f{i}.json")] = b"{}"
|
||||
return _FakeCommandResult(0, stdout="")
|
||||
|
||||
sandbox = _FakeSandbox(run_handler)
|
||||
provider, _ = _build_provider(sandbox, monkeypatch)
|
||||
provider.max_artifacts = 2
|
||||
|
||||
inst = provider.create_instance("python")
|
||||
with pytest.raises(RuntimeError, match="more than 2 artifacts"):
|
||||
provider.execute_code(inst.instance_id, "def main():\n return 1\n", "python", timeout=5)
|
||||
|
||||
|
||||
def test_tenki_provider_enforces_max_artifact_bytes(monkeypatch):
|
||||
def run_handler(sandbox, argv, cwd):
|
||||
sandbox.fs.files[posixpath.join(cwd, "artifacts", "big.json")] = b"x" * 5000
|
||||
return _FakeCommandResult(0, stdout="")
|
||||
|
||||
sandbox = _FakeSandbox(run_handler)
|
||||
provider, _ = _build_provider(sandbox, monkeypatch)
|
||||
provider.max_artifact_bytes = 100
|
||||
|
||||
inst = provider.create_instance("python")
|
||||
with pytest.raises(RuntimeError, match="exceeds 100 bytes"):
|
||||
provider.execute_code(inst.instance_id, "def main():\n return 1\n", "python", timeout=5)
|
||||
@@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
LucideChevronDown,
|
||||
LucideCloud,
|
||||
LucideCloudLightning,
|
||||
LucideLink,
|
||||
LucideLoader2,
|
||||
LucideMonitor,
|
||||
@@ -62,6 +63,7 @@ const PROVIDER_ICONS: Record<string, React.ElementType> = {
|
||||
ssh: LucideTerminal,
|
||||
aliyun_codeinterpreter: LucideCloud,
|
||||
e2b: LucideZap,
|
||||
tenki: LucideCloudLightning,
|
||||
};
|
||||
|
||||
function AdminSandboxSettings() {
|
||||
|
||||
Reference in New Issue
Block a user