sqlsaber-sandbox 0.1.1__py3-none-any.whl
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.
|
@@ -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,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,6 @@
|
|
|
1
|
+
sqlsaber_sandbox/__init__.py,sha256=aoTmq2gQOowAvtI7j-XJ1CGLKXLjIPPH9PxH6CWRjH0,96
|
|
2
|
+
sqlsaber_sandbox/tools.py,sha256=xo2NIP_wtyI6EOtIDCT6B3EhWypQtymtDMTpdQmt3oc,9608
|
|
3
|
+
sqlsaber_sandbox-0.1.1.dist-info/METADATA,sha256=9FgsixzRaTXS3Hfep1EJKnvv1FvcKjdh0Z6P55fKQZw,339
|
|
4
|
+
sqlsaber_sandbox-0.1.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
5
|
+
sqlsaber_sandbox-0.1.1.dist-info/entry_points.txt,sha256=KMuoZYrV5EyctYGRP2-gTM-zPLSbemFGR6F1MoV6ZIk,59
|
|
6
|
+
sqlsaber_sandbox-0.1.1.dist-info/RECORD,,
|