feat(tools): add OpenSandbox sandbox tool with optional deps

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
iris-clawd
2026-05-08 23:02:12 +00:00
parent 160e25c1a9
commit 9c981e175b
7 changed files with 594 additions and 0 deletions

View File

@@ -142,6 +142,9 @@ contextual = [
daytona = [
"daytona~=0.140.0",
]
opensandbox = [
"opensandbox>=0.1.8",
]
[tool.uv]

View File

@@ -114,6 +114,11 @@ from crewai_tools.tools.multion_tool.multion_tool import MultiOnTool
from crewai_tools.tools.mysql_search_tool.mysql_search_tool import MySQLSearchTool
from crewai_tools.tools.nl2sql.nl2sql_tool import NL2SQLTool
from crewai_tools.tools.ocr_tool.ocr_tool import OCRTool
from crewai_tools.tools.open_sandbox_tool import (
OpenSandboxBaseTool,
OpenSandboxExecTool,
OpenSandboxFileTool,
)
from crewai_tools.tools.oxylabs_amazon_product_scraper_tool.oxylabs_amazon_product_scraper_tool import (
OxylabsAmazonProductScraperTool,
)
@@ -267,6 +272,9 @@ __all__ = [
"MySQLSearchTool",
"NL2SQLTool",
"OCRTool",
"OpenSandboxBaseTool",
"OpenSandboxExecTool",
"OpenSandboxFileTool",
"OxylabsAmazonProductScraperTool",
"OxylabsAmazonSearchScraperTool",
"OxylabsGoogleSearchScraperTool",

View File

@@ -104,6 +104,11 @@ from crewai_tools.tools.multion_tool.multion_tool import MultiOnTool
from crewai_tools.tools.mysql_search_tool.mysql_search_tool import MySQLSearchTool
from crewai_tools.tools.nl2sql.nl2sql_tool import NL2SQLTool
from crewai_tools.tools.ocr_tool.ocr_tool import OCRTool
from crewai_tools.tools.open_sandbox_tool import (
OpenSandboxBaseTool,
OpenSandboxExecTool,
OpenSandboxFileTool,
)
from crewai_tools.tools.oxylabs_amazon_product_scraper_tool.oxylabs_amazon_product_scraper_tool import (
OxylabsAmazonProductScraperTool,
)
@@ -251,6 +256,9 @@ __all__ = [
"MySQLSearchTool",
"NL2SQLTool",
"OCRTool",
"OpenSandboxBaseTool",
"OpenSandboxExecTool",
"OpenSandboxFileTool",
"OxylabsAmazonProductScraperTool",
"OxylabsAmazonSearchScraperTool",
"OxylabsGoogleSearchScraperTool",

View File

@@ -0,0 +1,16 @@
from crewai_tools.tools.open_sandbox_tool.open_sandbox_base_tool import (
OpenSandboxBaseTool,
)
from crewai_tools.tools.open_sandbox_tool.open_sandbox_exec_tool import (
OpenSandboxExecTool,
)
from crewai_tools.tools.open_sandbox_tool.open_sandbox_file_tool import (
OpenSandboxFileTool,
)
__all__ = [
"OpenSandboxBaseTool",
"OpenSandboxExecTool",
"OpenSandboxFileTool",
]

View File

@@ -0,0 +1,229 @@
from __future__ import annotations
import atexit
import logging
import os
import threading
from typing import Any, ClassVar
from crewai.tools import BaseTool, EnvVar
from pydantic import ConfigDict, Field, PrivateAttr
logger = logging.getLogger(__name__)
class OpenSandboxBaseTool(BaseTool):
"""Shared base for tools that act on an Open Sandbox sandbox.
Lifecycle modes:
- persistent=False (default): create a fresh sandbox per `_run` call and
kill it when the call returns. Safer and stateless — nothing leaks if
the agent forgets cleanup.
- persistent=True: lazily create a single sandbox on first use, cache it
on the instance, and register an atexit hook to kill it at process
exit. Cheaper across many calls and lets files/state carry over.
- sandbox_id=<existing>: attach to a sandbox the caller already owns.
Never killed by the tool.
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
package_dependencies: list[str] = Field(default_factory=lambda: ["opensandbox"])
api_key: str | None = Field(
default_factory=lambda: os.getenv("OPEN_SANDBOX_API_KEY"),
description="Open Sandbox API key. Falls back to OPEN_SANDBOX_API_KEY env var.",
json_schema_extra={"required": False},
)
domain: str | None = Field(
default_factory=lambda: os.getenv("OPEN_SANDBOX_DOMAIN"),
description=(
"Open Sandbox management API domain (e.g. 'api.opensandbox.io'). "
"Falls back to OPEN_SANDBOX_DOMAIN env var."
),
json_schema_extra={"required": False},
)
protocol: str | None = Field(
default=None,
description="Protocol for the management API ('http' or 'https').",
json_schema_extra={"required": False},
)
persistent: bool = Field(
default=False,
description=(
"If True, reuse one sandbox across all calls to this tool instance "
"and kill it at process exit. Default False creates and kills a "
"fresh sandbox per call."
),
)
sandbox_id: str | None = Field(
default=None,
description=(
"Attach to an existing sandbox by id instead of creating a new one. "
"The tool will never kill a sandbox it did not create."
),
)
create_params: dict[str, Any] | None = Field(
default=None,
description=(
"Optional kwargs forwarded to SandboxSync.create when creating a "
"sandbox (e.g. image, env, resource, metadata, entrypoint)."
),
)
sandbox_timeout: float = Field(
default=60.0,
description=(
"Timeout in seconds to wait for sandbox readiness on create/connect."
),
)
env_vars: list[EnvVar] = Field(
default_factory=lambda: [
EnvVar(
name="OPEN_SANDBOX_API_KEY",
description="API key for Open Sandbox service",
required=False,
),
EnvVar(
name="OPEN_SANDBOX_DOMAIN",
description="Open Sandbox management API domain (optional)",
required=False,
),
]
)
_client: Any | None = PrivateAttr(default=None)
_persistent_sandbox: Any | None = PrivateAttr(default=None)
_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock)
_cleanup_registered: bool = PrivateAttr(default=False)
_sdk_cache: ClassVar[dict[str, Any]] = {}
@classmethod
def _import_sdk(cls) -> dict[str, Any]:
if cls._sdk_cache:
return cls._sdk_cache
try:
from opensandbox.config.connection_sync import ConnectionConfigSync
from opensandbox.models.execd import RunCommandOpts
from opensandbox.models.filesystem import SearchEntry, WriteEntry
from opensandbox.sync.sandbox import SandboxSync
except ImportError as exc:
raise ImportError(
"The 'opensandbox' package is required for Open Sandbox tools. "
"Install it with: uv add opensandbox (or) pip install opensandbox"
) from exc
cls._sdk_cache = {
"SandboxSync": SandboxSync,
"ConnectionConfigSync": ConnectionConfigSync,
"RunCommandOpts": RunCommandOpts,
"WriteEntry": WriteEntry,
"SearchEntry": SearchEntry,
}
return cls._sdk_cache
def _get_client(self) -> Any:
"""Return a cached ConnectionConfigSync built from this tool's fields.
Open Sandbox has no separate "client" object — connection settings are
carried by ConnectionConfigSync and passed into SandboxSync.create /
SandboxSync.connect. We cache one config per tool instance.
"""
if self._client is not None:
return self._client
sdk = self._import_sdk()
config_kwargs: dict[str, Any] = {}
if self.api_key:
config_kwargs["api_key"] = self.api_key
if self.domain:
config_kwargs["domain"] = self.domain
if self.protocol:
config_kwargs["protocol"] = self.protocol
self._client = sdk["ConnectionConfigSync"](**config_kwargs)
return self._client
def _build_create_kwargs(self) -> dict[str, Any]:
return dict(self.create_params) if self.create_params else {}
def _acquire_sandbox(self) -> tuple[Any, bool]:
"""Return (sandbox, should_kill_after_use)."""
sdk = self._import_sdk()
config = self._get_client()
if self.sandbox_id:
sandbox = sdk["SandboxSync"].connect(
self.sandbox_id,
connection_config=config,
connect_timeout=_seconds_to_timedelta(self.sandbox_timeout),
)
return sandbox, False
if self.persistent:
with self._lock:
if self._persistent_sandbox is None:
self._persistent_sandbox = sdk["SandboxSync"].create(
connection_config=config,
ready_timeout=_seconds_to_timedelta(self.sandbox_timeout),
**self._build_create_kwargs(),
)
if not self._cleanup_registered:
atexit.register(self.close)
self._cleanup_registered = True
return self._persistent_sandbox, False
sandbox = sdk["SandboxSync"].create(
connection_config=config,
ready_timeout=_seconds_to_timedelta(self.sandbox_timeout),
**self._build_create_kwargs(),
)
return sandbox, True
def _release_sandbox(self, sandbox: Any, should_kill: bool) -> None:
if not should_kill:
return
try:
sandbox.kill()
except Exception:
logger.debug(
"Best-effort sandbox kill failed after ephemeral use; "
"the sandbox may need manual termination.",
exc_info=True,
)
try:
sandbox.close()
except Exception:
logger.debug(
"Best-effort sandbox local-resource close failed after ephemeral use.",
exc_info=True,
)
def close(self) -> None:
"""Kill the cached persistent sandbox if one exists."""
with self._lock:
sandbox = self._persistent_sandbox
self._persistent_sandbox = None
if sandbox is None:
return
try:
sandbox.kill()
except Exception:
logger.debug(
"Best-effort persistent sandbox kill failed at close(); "
"the sandbox may need manual termination.",
exc_info=True,
)
try:
sandbox.close()
except Exception:
logger.debug(
"Best-effort persistent sandbox local-resource close failed at close().",
exc_info=True,
)
def _seconds_to_timedelta(seconds: float) -> Any:
from datetime import timedelta
return timedelta(seconds=seconds)

View File

@@ -0,0 +1,102 @@
from __future__ import annotations
from builtins import type as type_
from datetime import timedelta
from typing import Any
from pydantic import BaseModel, Field
from crewai_tools.tools.open_sandbox_tool.open_sandbox_base_tool import (
OpenSandboxBaseTool,
)
class OpenSandboxExecToolSchema(BaseModel):
command: str = Field(..., description="Shell command to execute in the sandbox.")
cwd: str | None = Field(
default=None,
description="Working directory to run the command in. Defaults to the sandbox work dir.",
)
env: dict[str, str] | None = Field(
default=None,
description="Optional environment variables to set for this command.",
)
timeout: int | None = Field(
default=None,
description="Maximum seconds to wait for the command to finish.",
)
class OpenSandboxExecTool(OpenSandboxBaseTool):
"""Run a shell command inside an Open Sandbox sandbox."""
name: str = "Open Sandbox Exec"
description: str = (
"Execute a shell command inside an Open Sandbox sandbox and return the "
"exit code and combined output. Use this to run builds, package installs, "
"git operations, or any one-off shell command."
)
args_schema: type_[BaseModel] = OpenSandboxExecToolSchema
def _run(
self,
command: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: int | None = None,
) -> Any:
sdk = self._import_sdk()
sandbox, should_kill = self._acquire_sandbox()
try:
opts = self._build_run_opts(sdk, cwd=cwd, env=env, timeout=timeout)
execution = sandbox.commands.run(command, opts=opts)
return {
"exit_code": getattr(execution, "exit_code", None),
"result": getattr(execution, "text", None),
"artifacts": _collect_artifacts(execution),
}
finally:
self._release_sandbox(sandbox, should_kill)
@staticmethod
def _build_run_opts(
sdk: dict[str, Any],
*,
cwd: str | None,
env: dict[str, str] | None,
timeout: int | None,
) -> Any | None:
if cwd is None and env is None and timeout is None:
return None
kwargs: dict[str, Any] = {}
if cwd is not None:
kwargs["working_directory"] = cwd
if env is not None:
kwargs["envs"] = env
if timeout is not None:
kwargs["timeout"] = timedelta(seconds=timeout)
return sdk["RunCommandOpts"](**kwargs)
def _collect_artifacts(execution: Any) -> dict[str, Any] | None:
logs = getattr(execution, "logs", None)
stderr_msgs = getattr(logs, "stderr", None) if logs is not None else None
results = getattr(execution, "result", None)
error = getattr(execution, "error", None)
if not stderr_msgs and not results and error is None:
return None
return {
"stderr": [getattr(m, "text", str(m)) for m in stderr_msgs or []],
"results": [getattr(r, "text", None) for r in results or []],
"error": _serialize_error(error),
}
def _serialize_error(error: Any) -> dict[str, Any] | None:
if error is None:
return None
return {
"name": getattr(error, "name", None),
"value": getattr(error, "value", None),
"traceback": getattr(error, "traceback", None),
}

View File

@@ -0,0 +1,228 @@
from __future__ import annotations
import base64
from builtins import type as type_
import logging
import posixpath
from typing import Any, Literal
from pydantic import BaseModel, Field, model_validator
from crewai_tools.tools.open_sandbox_tool.open_sandbox_base_tool import (
OpenSandboxBaseTool,
)
logger = logging.getLogger(__name__)
FileAction = Literal["read", "write", "append", "list", "delete", "mkdir", "info"]
class OpenSandboxFileToolSchema(BaseModel):
action: FileAction = Field(
...,
description=(
"The filesystem action to perform: 'read' (returns file contents), "
"'write' (create or replace a file with content), 'append' (append "
"content to an existing file — use this for writing large files in "
"chunks to avoid hitting tool-call size limits), 'list' (lists a "
"directory), 'delete' (removes a file/dir), 'mkdir' (creates a "
"directory), 'info' (returns file metadata)."
),
)
path: str = Field(..., description="Absolute path inside the sandbox.")
content: str | None = Field(
default=None,
description=(
"Content to write or append. If omitted for 'write', an empty file "
"is created. For files larger than a few KB, prefer one 'write' "
"with empty content followed by multiple 'append' calls of ~4KB "
"each to stay within tool-call payload limits."
),
)
binary: bool = Field(
default=False,
description=(
"For 'write': treat content as base64 and upload raw bytes. "
"For 'read': return contents as base64 instead of decoded utf-8."
),
)
recursive: bool = Field(
default=False,
description="For action='delete': remove a directory recursively.",
)
mode: int = Field(
default=755,
description="For action='mkdir': Unix file mode as an integer (default 755).",
)
@model_validator(mode="after")
def _validate_action_args(self) -> OpenSandboxFileToolSchema:
if self.action == "append" and self.content is None:
raise ValueError(
"action='append' requires 'content'. Pass the chunk to append "
"in the 'content' field."
)
return self
class OpenSandboxFileTool(OpenSandboxBaseTool):
"""Read, write, and manage files inside an Open Sandbox sandbox.
Notes:
- Most useful with `persistent=True` or an explicit `sandbox_id`. With the
default ephemeral mode, files disappear when this tool call finishes.
"""
name: str = "Open Sandbox File"
description: str = (
"Perform filesystem operations inside an Open Sandbox sandbox: read a "
"file, write content to a path, append content to an existing file, "
"list a directory, delete a path, make a directory, or fetch file "
"metadata. For files larger than a few KB, create the file with "
"action='write' and empty content, then send the body via multiple "
"'append' calls of ~4KB each to stay within tool-call payload limits."
)
args_schema: type_[BaseModel] = OpenSandboxFileToolSchema
def _run(
self,
action: FileAction,
path: str,
content: str | None = None,
binary: bool = False,
recursive: bool = False,
mode: int = 755,
) -> Any:
sandbox, should_kill = self._acquire_sandbox()
try:
if action == "read":
return self._read(sandbox, path, binary=binary)
if action == "write":
return self._write(sandbox, path, content or "", binary=binary)
if action == "append":
return self._append(sandbox, path, content or "", binary=binary)
if action == "list":
return self._list(sandbox, path)
if action == "delete":
return self._delete(sandbox, path, recursive=recursive)
if action == "mkdir":
return self._mkdir(sandbox, path, mode=mode)
if action == "info":
return self._info(sandbox, path)
raise ValueError(f"Unknown action: {action}")
finally:
self._release_sandbox(sandbox, should_kill)
def _read(self, sandbox: Any, path: str, *, binary: bool) -> dict[str, Any]:
if binary:
data: bytes = sandbox.files.read_bytes(path)
return {
"path": path,
"encoding": "base64",
"content": base64.b64encode(data).decode("ascii"),
}
try:
text: str = sandbox.files.read_file(path)
return {"path": path, "encoding": "utf-8", "content": text}
except UnicodeDecodeError:
data = sandbox.files.read_bytes(path)
return {
"path": path,
"encoding": "base64",
"content": base64.b64encode(data).decode("ascii"),
"note": "File was not valid utf-8; returned as base64.",
}
def _write(
self, sandbox: Any, path: str, content: str, *, binary: bool
) -> dict[str, Any]:
payload = base64.b64decode(content) if binary else content.encode("utf-8")
self._ensure_parent_dir(sandbox, path)
sandbox.files.write_file(path, payload)
return {"status": "written", "path": path, "bytes": len(payload)}
def _append(
self, sandbox: Any, path: str, content: str, *, binary: bool
) -> dict[str, Any]:
chunk = base64.b64decode(content) if binary else content.encode("utf-8")
self._ensure_parent_dir(sandbox, path)
try:
existing: bytes = sandbox.files.read_bytes(path)
except Exception:
existing = b""
payload = existing + chunk
sandbox.files.write_file(path, payload)
return {
"status": "appended",
"path": path,
"appended_bytes": len(chunk),
"total_bytes": len(payload),
}
def _ensure_parent_dir(self, sandbox: Any, path: str) -> None:
"""Make sure the parent directory of `path` exists.
Best-effort mkdir of the parent; any error (e.g. already exists) is
swallowed because create_directories may not be idempotent.
"""
parent = posixpath.dirname(path)
if not parent or parent in ("/", "."):
return
sdk = self._import_sdk()
try:
sandbox.files.create_directories([sdk["WriteEntry"](path=parent, mode=755)])
except Exception:
logger.debug(
"Best-effort parent-directory create failed for %s; "
"assuming it already exists and proceeding with the write.",
parent,
exc_info=True,
)
def _mkdir(self, sandbox: Any, path: str, *, mode: int) -> dict[str, Any]:
sdk = self._import_sdk()
sandbox.files.create_directories([sdk["WriteEntry"](path=path, mode=mode)])
return {"status": "created", "path": path, "mode": mode}
def _delete(self, sandbox: Any, path: str, *, recursive: bool) -> dict[str, Any]:
if recursive:
sandbox.files.delete_directories([path])
else:
sandbox.files.delete_files([path])
return {"status": "deleted", "path": path}
def _list(self, sandbox: Any, path: str) -> dict[str, Any]:
sdk = self._import_sdk()
entries = sandbox.files.search(sdk["SearchEntry"](path=path, pattern="*"))
return {
"path": path,
"entries": [self._entry_info_to_dict(entry) for entry in entries],
}
def _info(self, sandbox: Any, path: str) -> dict[str, Any]:
info_map = sandbox.files.get_file_info([path])
info = info_map.get(path) if hasattr(info_map, "get") else None
if info is None:
return {"path": path, "found": False}
return self._entry_info_to_dict(info)
@staticmethod
def _entry_info_to_dict(info: Any) -> dict[str, Any]:
fields = (
"path",
"mode",
"owner",
"group",
"size",
"modified_at",
"created_at",
)
out: dict[str, Any] = {}
for field in fields:
value = getattr(info, field, None)
if hasattr(value, "isoformat"):
value = value.isoformat()
out[field] = value
return out