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,5 @@
1
+ """SQLSaber sandbox plugin."""
2
+
3
+ from .tools import register_tools
4
+
5
+ __all__ = ["register_tools"]
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [sqlsaber.tools]
2
+ sandbox = sqlsaber_sandbox:register_tools