sqlsaber-sandbox 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sqlsaber_sandbox-0.1.1/.gitignore +12 -0
- sqlsaber_sandbox-0.1.1/CHANGELOG.md +8 -0
- sqlsaber_sandbox-0.1.1/PKG-INFO +12 -0
- sqlsaber_sandbox-0.1.1/README.md +3 -0
- sqlsaber_sandbox-0.1.1/pyproject.toml +20 -0
- sqlsaber_sandbox-0.1.1/pytest.ini +3 -0
- sqlsaber_sandbox-0.1.1/src/sqlsaber_sandbox/__init__.py +5 -0
- sqlsaber_sandbox-0.1.1/src/sqlsaber_sandbox/tools.py +274 -0
- sqlsaber_sandbox-0.1.1/tests/test_sandbox_tools.py +305 -0
- sqlsaber_sandbox-0.1.1/uv.lock +3845 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.1](https://github.com/SarthakJariwala/sqlsaber/compare/sqlsaber-sandbox-v0.1.0...sqlsaber-sandbox-v0.1.1) (2026-02-05)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* first plugins release ([bd1a5b5](https://github.com/SarthakJariwala/sqlsaber/commit/bd1a5b5079d6eaff2a52bdf4b8812d54ea8c2783))
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlsaber-sandbox
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: SQLsaber sandbox tool plugin
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: cased-sandboxes>=0.5.0
|
|
7
|
+
Requires-Dist: sqlsaber>=0.54.0
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# SQLSaber Sandbox Plugin
|
|
11
|
+
|
|
12
|
+
Provides the `run_python` tool for SQLsaber via the `sqlsaber-sandbox` plugin.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sqlsaber-sandbox"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "SQLsaber sandbox tool plugin"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = ["sqlsaber>=0.54.0", "cased-sandboxes>=0.5.0"]
|
|
8
|
+
|
|
9
|
+
[project.entry-points."sqlsaber.tools"]
|
|
10
|
+
sandbox = "sqlsaber_sandbox:register_tools"
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["hatchling"]
|
|
14
|
+
build-backend = "hatchling.build"
|
|
15
|
+
|
|
16
|
+
[tool.hatch.build.targets.wheel]
|
|
17
|
+
packages = ["src/sqlsaber_sandbox"]
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = ["pytest>=9.0.2", "pytest-asyncio>=0.25.0"]
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Sandboxed Python execution tools."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import tempfile
|
|
9
|
+
from typing import Iterable
|
|
10
|
+
|
|
11
|
+
from pydantic_ai import RunContext
|
|
12
|
+
|
|
13
|
+
from sqlsaber.tools.base import Tool
|
|
14
|
+
from sqlsaber.tools.display import (
|
|
15
|
+
DisplayMetadata,
|
|
16
|
+
ExecutingConfig,
|
|
17
|
+
FieldMappings,
|
|
18
|
+
ResultConfig,
|
|
19
|
+
ToolDisplaySpec,
|
|
20
|
+
)
|
|
21
|
+
from sqlsaber.tools.registry import ToolRegistry
|
|
22
|
+
from sqlsaber.utils.json_utils import json_dumps
|
|
23
|
+
|
|
24
|
+
PROVIDER_ENV_REQUIREMENTS: dict[str, tuple[str, ...]] = {
|
|
25
|
+
"daytona": ("DAYTONA_API_KEY",),
|
|
26
|
+
"e2b": ("E2B_API_KEY",),
|
|
27
|
+
"sprites": ("SPRITES_TOKEN",),
|
|
28
|
+
"hopx": ("HOPX_API_KEY",),
|
|
29
|
+
"modal": ("MODAL_TOKEN_ID", "MODAL_TOKEN_SECRET"),
|
|
30
|
+
"cloudflare": ("CLOUDFLARE_SANDBOX_BASE_URL", "CLOUDFLARE_API_TOKEN"),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
DEFAULT_TIMEOUT_SECONDS = 120
|
|
34
|
+
MAX_TIMEOUT_SECONDS = 600
|
|
35
|
+
MAX_CODE_CHARS = 20000
|
|
36
|
+
MAX_REQUIREMENTS = 10
|
|
37
|
+
MAX_REQUIREMENT_CHARS = 200
|
|
38
|
+
TOOL_OUTPUT_FILE_PATTERN = re.compile(r"^result_[A-Za-z0-9._-]+\.json$")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _has_env_values(names: Iterable[str]) -> bool:
|
|
42
|
+
return all(os.getenv(name) for name in names)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _modal_config_available() -> bool:
|
|
46
|
+
config_path = os.getenv("MODAL_CONFIG_PATH")
|
|
47
|
+
modal_config = (
|
|
48
|
+
os.path.expanduser(config_path)
|
|
49
|
+
if config_path
|
|
50
|
+
else os.path.expanduser("~/.modal.toml")
|
|
51
|
+
)
|
|
52
|
+
return os.path.isfile(modal_config)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def sandbox_providers_available() -> bool:
|
|
56
|
+
"""Return True when at least one sandbox provider is configured."""
|
|
57
|
+
|
|
58
|
+
for env_names in PROVIDER_ENV_REQUIREMENTS.values():
|
|
59
|
+
if _has_env_values(env_names):
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
if _modal_config_available():
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def register_tools(registry: ToolRegistry | None = None):
|
|
69
|
+
"""Register sandbox tools when providers are available.
|
|
70
|
+
|
|
71
|
+
Returns list of tool classes when registration should occur.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
if not sandbox_providers_available():
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
tool_classes = [RunPythonTool]
|
|
78
|
+
if registry is not None:
|
|
79
|
+
for tool_class in tool_classes:
|
|
80
|
+
if tool_class().name in registry.list_tools():
|
|
81
|
+
return None
|
|
82
|
+
registry.register(tool_class)
|
|
83
|
+
return tool_classes
|
|
84
|
+
|
|
85
|
+
return tool_classes
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _build_python_command(code: str) -> str:
|
|
89
|
+
encoded = base64.b64encode(code.encode("utf-8")).decode("ascii")
|
|
90
|
+
return (
|
|
91
|
+
'python -c "import base64; '
|
|
92
|
+
f"code=base64.b64decode('{encoded}').decode('utf-8'); "
|
|
93
|
+
"exec(compile(code, '<sandbox>', 'exec'))\""
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class RunPythonTool(Tool):
|
|
98
|
+
"""Run Python code in a sandboxed environment."""
|
|
99
|
+
|
|
100
|
+
requires_ctx = True
|
|
101
|
+
|
|
102
|
+
display_spec = ToolDisplaySpec(
|
|
103
|
+
executing=ExecutingConfig(
|
|
104
|
+
message="Running Python in sandbox",
|
|
105
|
+
icon="🐍",
|
|
106
|
+
show_args=["requirements", "timeout_seconds"],
|
|
107
|
+
),
|
|
108
|
+
result=ResultConfig(
|
|
109
|
+
format="panel",
|
|
110
|
+
title="Python Output",
|
|
111
|
+
fields=FieldMappings(output="stdout", error="stderr", success="success"),
|
|
112
|
+
),
|
|
113
|
+
metadata=DisplayMetadata(display_name="Run Python"),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def name(self) -> str:
|
|
118
|
+
return "run_python"
|
|
119
|
+
|
|
120
|
+
async def execute(
|
|
121
|
+
self,
|
|
122
|
+
ctx: RunContext,
|
|
123
|
+
code: str,
|
|
124
|
+
requirements: list[str] | None = None,
|
|
125
|
+
file: str | None = None,
|
|
126
|
+
timeout_seconds: int | None = None,
|
|
127
|
+
) -> str:
|
|
128
|
+
"""Execute Python code inside a remote sandbox.
|
|
129
|
+
|
|
130
|
+
Notes:
|
|
131
|
+
- To use a SQL result file, you MUST pass `file` parameter.
|
|
132
|
+
The file is uploaded to `/tmp/<file>` inside the sandbox.
|
|
133
|
+
- Only stdout/stderr is returned. Use `print(...)` (or write to stdout)
|
|
134
|
+
to see output.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
code: Python code to execute.
|
|
138
|
+
requirements: Optional pip requirements to install before execution.
|
|
139
|
+
file: Optional file key from a previous tool output to upload.
|
|
140
|
+
When provided, the file is uploaded to `/tmp/<file>`.
|
|
141
|
+
timeout_seconds: Optional timeout for sandbox execution (seconds).
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
if not sandbox_providers_available():
|
|
145
|
+
return json_dumps(
|
|
146
|
+
{
|
|
147
|
+
"error": (
|
|
148
|
+
"No sandbox provider configured. Set at least one provider API "
|
|
149
|
+
"key (e.g., E2B_API_KEY, DAYTONA_API_KEY, SPRITES_TOKEN, "
|
|
150
|
+
"HOPX_API_KEY, MODAL_TOKEN_ID/MODAL_TOKEN_SECRET, or "
|
|
151
|
+
"CLOUDFLARE_SANDBOX_BASE_URL/CLOUDFLARE_API_TOKEN)."
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if not code or not code.strip():
|
|
157
|
+
return json_dumps({"error": "No Python code provided."})
|
|
158
|
+
|
|
159
|
+
if len(code) > MAX_CODE_CHARS:
|
|
160
|
+
return json_dumps(
|
|
161
|
+
{"error": f"Python code too large (max {MAX_CODE_CHARS} characters)."}
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
cleaned_requirements = [
|
|
165
|
+
req.strip() for req in (requirements or []) if req.strip()
|
|
166
|
+
]
|
|
167
|
+
if len(cleaned_requirements) > MAX_REQUIREMENTS:
|
|
168
|
+
return json_dumps(
|
|
169
|
+
{"error": (f"Too many requirements (max {MAX_REQUIREMENTS}).")}
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if any(len(req) > MAX_REQUIREMENT_CHARS for req in cleaned_requirements):
|
|
173
|
+
return json_dumps({"error": ("Requirement entry too long.")})
|
|
174
|
+
|
|
175
|
+
timeout_value = timeout_seconds or DEFAULT_TIMEOUT_SECONDS
|
|
176
|
+
if timeout_value < 1:
|
|
177
|
+
timeout_value = 1
|
|
178
|
+
if timeout_value > MAX_TIMEOUT_SECONDS:
|
|
179
|
+
timeout_value = MAX_TIMEOUT_SECONDS
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
from sandboxes import Sandbox
|
|
183
|
+
|
|
184
|
+
async with Sandbox.create(timeout=timeout_value) as sandbox:
|
|
185
|
+
remote_data_path = None
|
|
186
|
+
if file:
|
|
187
|
+
if not TOOL_OUTPUT_FILE_PATTERN.match(file):
|
|
188
|
+
return json_dumps(
|
|
189
|
+
{
|
|
190
|
+
"error": "Invalid data file key format.",
|
|
191
|
+
}
|
|
192
|
+
)
|
|
193
|
+
tool_call_id = file.removeprefix("result_").removesuffix(".json")
|
|
194
|
+
payload = _find_tool_output_payload(ctx, tool_call_id)
|
|
195
|
+
if payload is None:
|
|
196
|
+
return json_dumps(
|
|
197
|
+
{
|
|
198
|
+
"error": "Tool output not found in message history.",
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
remote_data_path = f"/tmp/{file}"
|
|
202
|
+
temp_path = None
|
|
203
|
+
try:
|
|
204
|
+
with tempfile.NamedTemporaryFile(
|
|
205
|
+
mode="w",
|
|
206
|
+
suffix=".json",
|
|
207
|
+
delete=False,
|
|
208
|
+
encoding="utf-8",
|
|
209
|
+
) as temp_file:
|
|
210
|
+
temp_file.write(json_dumps(payload))
|
|
211
|
+
temp_path = temp_file.name
|
|
212
|
+
await sandbox.upload(temp_path, remote_data_path)
|
|
213
|
+
finally:
|
|
214
|
+
if temp_path:
|
|
215
|
+
try:
|
|
216
|
+
os.unlink(temp_path)
|
|
217
|
+
except OSError:
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
if cleaned_requirements:
|
|
221
|
+
install_command = (
|
|
222
|
+
"python -m pip install --quiet --disable-pip-version-check "
|
|
223
|
+
"--no-input "
|
|
224
|
+
+ " ".join(shlex.quote(req) for req in cleaned_requirements)
|
|
225
|
+
)
|
|
226
|
+
install_result = await sandbox.execute(install_command)
|
|
227
|
+
if install_result.exit_code != 0:
|
|
228
|
+
return json_dumps(
|
|
229
|
+
{
|
|
230
|
+
"error": "Failed to install requirements.",
|
|
231
|
+
"exit_code": install_result.exit_code,
|
|
232
|
+
"stdout": install_result.stdout,
|
|
233
|
+
"stderr": install_result.stderr,
|
|
234
|
+
}
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
command = _build_python_command(code)
|
|
238
|
+
result = await sandbox.execute(command)
|
|
239
|
+
|
|
240
|
+
return json_dumps(
|
|
241
|
+
{
|
|
242
|
+
"success": result.success,
|
|
243
|
+
"exit_code": result.exit_code,
|
|
244
|
+
"stdout": result.stdout,
|
|
245
|
+
"stderr": result.stderr,
|
|
246
|
+
"data_path": remote_data_path,
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
except Exception as exc: # pragma: no cover - defensive catch-all
|
|
250
|
+
return json_dumps({"error": f"Python sandbox execution failed: {exc}"})
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _find_tool_output_payload(ctx: RunContext, tool_call_id: str) -> dict | None:
|
|
254
|
+
for message in reversed(ctx.messages):
|
|
255
|
+
for part in getattr(message, "parts", []):
|
|
256
|
+
if getattr(part, "part_kind", "") not in (
|
|
257
|
+
"tool-return",
|
|
258
|
+
"builtin-tool-return",
|
|
259
|
+
):
|
|
260
|
+
continue
|
|
261
|
+
if getattr(part, "tool_call_id", None) != tool_call_id:
|
|
262
|
+
continue
|
|
263
|
+
content = getattr(part, "content", None)
|
|
264
|
+
if isinstance(content, dict):
|
|
265
|
+
return content
|
|
266
|
+
if isinstance(content, str):
|
|
267
|
+
try:
|
|
268
|
+
parsed = json.loads(content)
|
|
269
|
+
except json.JSONDecodeError:
|
|
270
|
+
return {"result": content}
|
|
271
|
+
if isinstance(parsed, dict):
|
|
272
|
+
return parsed
|
|
273
|
+
return {"result": parsed}
|
|
274
|
+
return None
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Tests for sandbox tools."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
pytest.importorskip("sqlsaber_sandbox")
|
|
10
|
+
|
|
11
|
+
from sqlsaber_sandbox.tools import (
|
|
12
|
+
MAX_CODE_CHARS,
|
|
13
|
+
MAX_REQUIREMENTS,
|
|
14
|
+
RunPythonTool,
|
|
15
|
+
register_tools,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from sqlsaber.tools.registry import ToolRegistry
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _build_result(
|
|
22
|
+
exit_code: int = 0,
|
|
23
|
+
stdout: str = "",
|
|
24
|
+
stderr: str = "",
|
|
25
|
+
duration_ms: int | None = 5,
|
|
26
|
+
truncated: bool = False,
|
|
27
|
+
timed_out: bool = False,
|
|
28
|
+
success: bool = True,
|
|
29
|
+
) -> SimpleNamespace:
|
|
30
|
+
return SimpleNamespace(
|
|
31
|
+
exit_code=exit_code,
|
|
32
|
+
stdout=stdout,
|
|
33
|
+
stderr=stderr,
|
|
34
|
+
duration_ms=duration_ms,
|
|
35
|
+
truncated=truncated,
|
|
36
|
+
timed_out=timed_out,
|
|
37
|
+
success=success,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DummySandbox:
|
|
42
|
+
"""Minimal sandbox stand-in for unit tests."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, results: list[SimpleNamespace]):
|
|
45
|
+
self._results = list(results)
|
|
46
|
+
self.executed: list[str] = []
|
|
47
|
+
|
|
48
|
+
async def __aenter__(self) -> "DummySandbox":
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
async def execute(self, command: str) -> SimpleNamespace:
|
|
55
|
+
self.executed.append(command)
|
|
56
|
+
return self._results.pop(0)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DummySandboxFactory:
|
|
60
|
+
def __init__(self, sandbox: DummySandbox):
|
|
61
|
+
self._sandbox = sandbox
|
|
62
|
+
self.created_with: list[int] = []
|
|
63
|
+
|
|
64
|
+
def create(self, timeout: int) -> DummySandbox:
|
|
65
|
+
self.created_with.append(timeout)
|
|
66
|
+
return self._sandbox
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _patch_sandbox(monkeypatch: pytest.MonkeyPatch, sandbox: DummySandbox) -> None:
|
|
70
|
+
factory = DummySandboxFactory(sandbox)
|
|
71
|
+
module = SimpleNamespace(Sandbox=factory)
|
|
72
|
+
monkeypatch.setitem(sys.modules, "sandboxes", module)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _parse_result(payload: str) -> dict:
|
|
76
|
+
return json.loads(payload)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _make_ctx() -> SimpleNamespace:
|
|
80
|
+
return SimpleNamespace(messages=[])
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_register_sandbox_tools_disabled(
|
|
84
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
85
|
+
tmp_path,
|
|
86
|
+
) -> None:
|
|
87
|
+
for env_vars in [
|
|
88
|
+
"E2B_API_KEY",
|
|
89
|
+
"DAYTONA_API_KEY",
|
|
90
|
+
"SPRITES_TOKEN",
|
|
91
|
+
"HOPX_API_KEY",
|
|
92
|
+
"MODAL_TOKEN_ID",
|
|
93
|
+
"MODAL_TOKEN_SECRET",
|
|
94
|
+
"MODAL_CONFIG_PATH",
|
|
95
|
+
"CLOUDFLARE_SANDBOX_BASE_URL",
|
|
96
|
+
"CLOUDFLARE_API_TOKEN",
|
|
97
|
+
]:
|
|
98
|
+
monkeypatch.delenv(env_vars, raising=False)
|
|
99
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
100
|
+
registry = ToolRegistry()
|
|
101
|
+
|
|
102
|
+
registered = register_tools(registry)
|
|
103
|
+
|
|
104
|
+
assert registered is None
|
|
105
|
+
assert registry.list_tools() == []
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_register_sandbox_tools_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
109
|
+
monkeypatch.setenv("E2B_API_KEY", "test-key")
|
|
110
|
+
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
|
|
111
|
+
monkeypatch.delenv("MODAL_CONFIG_PATH", raising=False)
|
|
112
|
+
registry = ToolRegistry()
|
|
113
|
+
|
|
114
|
+
registered = register_tools(registry)
|
|
115
|
+
|
|
116
|
+
assert registered == [RunPythonTool]
|
|
117
|
+
assert "run_python" in registry.list_tools()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_register_sandbox_tools_disabled_with_modal_id_only(
|
|
121
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
122
|
+
tmp_path,
|
|
123
|
+
) -> None:
|
|
124
|
+
for env_vars in [
|
|
125
|
+
"E2B_API_KEY",
|
|
126
|
+
"DAYTONA_API_KEY",
|
|
127
|
+
"SPRITES_TOKEN",
|
|
128
|
+
"HOPX_API_KEY",
|
|
129
|
+
"MODAL_TOKEN_SECRET",
|
|
130
|
+
"MODAL_CONFIG_PATH",
|
|
131
|
+
"CLOUDFLARE_SANDBOX_BASE_URL",
|
|
132
|
+
"CLOUDFLARE_API_TOKEN",
|
|
133
|
+
]:
|
|
134
|
+
monkeypatch.delenv(env_vars, raising=False)
|
|
135
|
+
monkeypatch.setenv("MODAL_TOKEN_ID", "modal-id-only")
|
|
136
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
137
|
+
registry = ToolRegistry()
|
|
138
|
+
|
|
139
|
+
registered = register_tools(registry)
|
|
140
|
+
|
|
141
|
+
assert registered is None
|
|
142
|
+
assert registry.list_tools() == []
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_register_sandbox_tools_enabled_with_modal_config(
|
|
146
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
147
|
+
tmp_path,
|
|
148
|
+
) -> None:
|
|
149
|
+
for env_vars in [
|
|
150
|
+
"E2B_API_KEY",
|
|
151
|
+
"DAYTONA_API_KEY",
|
|
152
|
+
"SPRITES_TOKEN",
|
|
153
|
+
"HOPX_API_KEY",
|
|
154
|
+
"MODAL_TOKEN_ID",
|
|
155
|
+
"MODAL_TOKEN_SECRET",
|
|
156
|
+
"MODAL_CONFIG_PATH",
|
|
157
|
+
"CLOUDFLARE_SANDBOX_BASE_URL",
|
|
158
|
+
"CLOUDFLARE_API_TOKEN",
|
|
159
|
+
]:
|
|
160
|
+
monkeypatch.delenv(env_vars, raising=False)
|
|
161
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
162
|
+
modal_config = tmp_path / ".modal.toml"
|
|
163
|
+
modal_config.write_text('token_id = "test"\n', encoding="utf-8")
|
|
164
|
+
registry = ToolRegistry()
|
|
165
|
+
|
|
166
|
+
registered = register_tools(registry)
|
|
167
|
+
|
|
168
|
+
assert registered == [RunPythonTool]
|
|
169
|
+
assert "run_python" in registry.list_tools()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_register_sandbox_tools_enabled_with_modal_config_path(
|
|
173
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
174
|
+
tmp_path,
|
|
175
|
+
) -> None:
|
|
176
|
+
for env_vars in [
|
|
177
|
+
"E2B_API_KEY",
|
|
178
|
+
"DAYTONA_API_KEY",
|
|
179
|
+
"SPRITES_TOKEN",
|
|
180
|
+
"HOPX_API_KEY",
|
|
181
|
+
"MODAL_TOKEN_ID",
|
|
182
|
+
"MODAL_TOKEN_SECRET",
|
|
183
|
+
"CLOUDFLARE_SANDBOX_BASE_URL",
|
|
184
|
+
"CLOUDFLARE_API_TOKEN",
|
|
185
|
+
]:
|
|
186
|
+
monkeypatch.delenv(env_vars, raising=False)
|
|
187
|
+
config_path = tmp_path / "custom-modal.toml"
|
|
188
|
+
config_path.write_text('token_id = "test"\n', encoding="utf-8")
|
|
189
|
+
monkeypatch.setenv("MODAL_CONFIG_PATH", str(config_path))
|
|
190
|
+
registry = ToolRegistry()
|
|
191
|
+
|
|
192
|
+
registered = register_tools(registry)
|
|
193
|
+
|
|
194
|
+
assert registered == [RunPythonTool]
|
|
195
|
+
assert "run_python" in registry.list_tools()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@pytest.mark.asyncio
|
|
199
|
+
async def test_run_python_requires_provider(
|
|
200
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
201
|
+
tmp_path,
|
|
202
|
+
) -> None:
|
|
203
|
+
for env_var in [
|
|
204
|
+
"E2B_API_KEY",
|
|
205
|
+
"DAYTONA_API_KEY",
|
|
206
|
+
"SPRITES_TOKEN",
|
|
207
|
+
"HOPX_API_KEY",
|
|
208
|
+
"MODAL_TOKEN_ID",
|
|
209
|
+
"MODAL_TOKEN_SECRET",
|
|
210
|
+
"MODAL_CONFIG_PATH",
|
|
211
|
+
"CLOUDFLARE_SANDBOX_BASE_URL",
|
|
212
|
+
"CLOUDFLARE_API_TOKEN",
|
|
213
|
+
]:
|
|
214
|
+
monkeypatch.delenv(env_var, raising=False)
|
|
215
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
216
|
+
tool = RunPythonTool()
|
|
217
|
+
|
|
218
|
+
result = _parse_result(await tool.execute(_make_ctx(), code="print('hi')"))
|
|
219
|
+
|
|
220
|
+
assert "error" in result
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@pytest.mark.asyncio
|
|
224
|
+
async def test_run_python_rejects_large_payload(
|
|
225
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
226
|
+
) -> None:
|
|
227
|
+
monkeypatch.setenv("E2B_API_KEY", "test-key")
|
|
228
|
+
tool = RunPythonTool()
|
|
229
|
+
|
|
230
|
+
result = _parse_result(
|
|
231
|
+
await tool.execute(_make_ctx(), code="x" * (MAX_CODE_CHARS + 1))
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
assert "error" in result
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@pytest.mark.asyncio
|
|
238
|
+
async def test_run_python_rejects_many_requirements(
|
|
239
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
240
|
+
) -> None:
|
|
241
|
+
monkeypatch.setenv("E2B_API_KEY", "test-key")
|
|
242
|
+
tool = RunPythonTool()
|
|
243
|
+
|
|
244
|
+
requirements = [f"pkg{i}" for i in range(MAX_REQUIREMENTS + 1)]
|
|
245
|
+
result = _parse_result(
|
|
246
|
+
await tool.execute(_make_ctx(), code="print('ok')", requirements=requirements)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
assert "error" in result
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@pytest.mark.asyncio
|
|
253
|
+
async def test_run_python_executes_code(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
254
|
+
monkeypatch.setenv("E2B_API_KEY", "test-key")
|
|
255
|
+
sandbox = DummySandbox([_build_result(stdout="hello")])
|
|
256
|
+
_patch_sandbox(monkeypatch, sandbox)
|
|
257
|
+
|
|
258
|
+
tool = RunPythonTool()
|
|
259
|
+
result = _parse_result(await tool.execute(_make_ctx(), code="print('hello')"))
|
|
260
|
+
|
|
261
|
+
assert result["success"] is True
|
|
262
|
+
assert result["stdout"] == "hello"
|
|
263
|
+
assert sandbox.executed
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@pytest.mark.asyncio
|
|
267
|
+
async def test_run_python_installs_requirements(
|
|
268
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
269
|
+
) -> None:
|
|
270
|
+
monkeypatch.setenv("E2B_API_KEY", "test-key")
|
|
271
|
+
sandbox = DummySandbox(
|
|
272
|
+
[
|
|
273
|
+
_build_result(stdout="installed"),
|
|
274
|
+
_build_result(stdout="done"),
|
|
275
|
+
]
|
|
276
|
+
)
|
|
277
|
+
_patch_sandbox(monkeypatch, sandbox)
|
|
278
|
+
|
|
279
|
+
tool = RunPythonTool()
|
|
280
|
+
result = _parse_result(
|
|
281
|
+
await tool.execute(_make_ctx(), code="print('done')", requirements=["requests"])
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
assert result["success"] is True
|
|
285
|
+
assert len(sandbox.executed) == 2
|
|
286
|
+
assert "pip install" in sandbox.executed[0]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@pytest.mark.asyncio
|
|
290
|
+
async def test_run_python_handles_install_failure(
|
|
291
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
292
|
+
) -> None:
|
|
293
|
+
monkeypatch.setenv("E2B_API_KEY", "test-key")
|
|
294
|
+
sandbox = DummySandbox(
|
|
295
|
+
[_build_result(exit_code=1, stdout="", stderr="boom", success=False)]
|
|
296
|
+
)
|
|
297
|
+
_patch_sandbox(monkeypatch, sandbox)
|
|
298
|
+
|
|
299
|
+
tool = RunPythonTool()
|
|
300
|
+
result = _parse_result(
|
|
301
|
+
await tool.execute(_make_ctx(), code="print('done')", requirements=["requests"])
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
assert result["error"] == "Failed to install requirements."
|
|
305
|
+
assert result["exit_code"] == 1
|