mirror of https://github.com/dapr/dapr-agents.git
Merge pull request #97 from dapr/cyb3rward0g/update-local-executor
Executors: Sandbox support + per-project bootstrap + full refactor
This commit is contained in:
commit
c31e985d81
|
@ -0,0 +1,501 @@
|
||||||
|
{
|
||||||
|
"cells": [
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "39c2dcc0",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"# Executor: LocalCodeExecutor Basic Examples\n",
|
||||||
|
"\n",
|
||||||
|
"This notebook shows how to execute Python and shell snippets in **isolated, cached virtual environments**"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "c4ff4b2b",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Install Required Libraries\n",
|
||||||
|
"Before starting, ensure the required libraries are installed:"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "5b41a66a",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"!pip install dapr-agents"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "a9c01be3",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Setup"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 1,
|
||||||
|
"id": "508fd446",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"import logging\n",
|
||||||
|
"\n",
|
||||||
|
"from dapr_agents.executors.local import LocalCodeExecutor\n",
|
||||||
|
"from dapr_agents.types.executor import CodeSnippet, ExecutionRequest\n",
|
||||||
|
"from rich.console import Console\n",
|
||||||
|
"from rich.ansi import AnsiDecoder\n",
|
||||||
|
"import shutil"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 2,
|
||||||
|
"id": "27594072",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"logging.basicConfig(level=logging.INFO)\n",
|
||||||
|
"\n",
|
||||||
|
"executor = LocalCodeExecutor()\n",
|
||||||
|
"console = Console()\n",
|
||||||
|
"decoder = AnsiDecoder()"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "4d663475",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Running a basic Python Code Snippet"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 3,
|
||||||
|
"id": "ba45ddc8",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stderr",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"INFO:dapr_agents.executors.local:Sandbox backend enabled: seatbelt\n",
|
||||||
|
"INFO:dapr_agents.executors.local:Created a new virtual environment\n",
|
||||||
|
"INFO:dapr_agents.executors.local:Installing print, rich\n",
|
||||||
|
"INFO:dapr_agents.executors.local:Snippet 1 finished in 2.442s\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"text/html": [
|
||||||
|
"<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\"><span style=\"color: #008000; text-decoration-color: #008000; font-weight: bold\">Hello executor!</span>\n",
|
||||||
|
"</pre>\n"
|
||||||
|
],
|
||||||
|
"text/plain": [
|
||||||
|
"\u001b[1;32mHello executor!\u001b[0m\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "display_data"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"code = \"\"\"\n",
|
||||||
|
"from rich import print\n",
|
||||||
|
"print(\"[bold green]Hello executor![/bold green]\")\n",
|
||||||
|
"\"\"\"\n",
|
||||||
|
"\n",
|
||||||
|
"request = ExecutionRequest(snippets=[\n",
|
||||||
|
" CodeSnippet(language='python', code=code, timeout=10)\n",
|
||||||
|
"])\n",
|
||||||
|
"\n",
|
||||||
|
"results = await executor.execute(request)\n",
|
||||||
|
"results[0] # raw result\n",
|
||||||
|
"\n",
|
||||||
|
"# pretty‑print with Rich\n",
|
||||||
|
"console.print(*decoder.decode(results[0].output))"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "d28c7531",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Run a Shell Snipper"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 4,
|
||||||
|
"id": "4ea89b85",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stderr",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"INFO:dapr_agents.executors.local:Sandbox backend enabled: seatbelt\n",
|
||||||
|
"INFO:dapr_agents.executors.local:Snippet 1 finished in 0.019s\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"text/plain": [
|
||||||
|
"[ExecutionResult(status='success', output='4\\n', exit_code=0)]"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"execution_count": 4,
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "execute_result"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"shell_request = ExecutionRequest(snippets=[\n",
|
||||||
|
" CodeSnippet(language='sh', code='echo $((2+2))', timeout=5)\n",
|
||||||
|
"])\n",
|
||||||
|
"\n",
|
||||||
|
"await executor.execute(shell_request)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "da281b6e",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Reuse the cached virtual environment"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 5,
|
||||||
|
"id": "3e9e7e9b",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stderr",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"INFO:dapr_agents.executors.local:Sandbox backend enabled: seatbelt\n",
|
||||||
|
"INFO:dapr_agents.executors.local:Reusing cached virtual environment.\n",
|
||||||
|
"INFO:dapr_agents.executors.local:Installing print, rich\n",
|
||||||
|
"INFO:dapr_agents.executors.local:Snippet 1 finished in 0.297s\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"text/plain": [
|
||||||
|
"[ExecutionResult(status='success', output='\\x1b[1;32mHello executor!\\x1b[0m\\n', exit_code=0)]"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"execution_count": 5,
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "execute_result"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"# Re‑running the same Python request will reuse the cached venv, so it is faster\n",
|
||||||
|
"await executor.execute(request)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "14dc3e4c",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Inject Helper Functions"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 6,
|
||||||
|
"id": "82f9a168",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stderr",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"INFO:dapr_agents.executors.local:Sandbox backend enabled: seatbelt\n",
|
||||||
|
"INFO:dapr_agents.executors.local:Created a new virtual environment\n",
|
||||||
|
"INFO:dapr_agents.executors.local:Snippet 1 finished in 1.408s\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"text/plain": [
|
||||||
|
"[ExecutionResult(status='success', output='42\\n', exit_code=0)]"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"execution_count": 6,
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "execute_result"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"def fancy_sum(a: int, b: int) -> int:\n",
|
||||||
|
" return a + b\n",
|
||||||
|
"\n",
|
||||||
|
"executor.user_functions.append(fancy_sum)\n",
|
||||||
|
"\n",
|
||||||
|
"helper_request = ExecutionRequest(snippets=[\n",
|
||||||
|
" CodeSnippet(language='python', code='print(fancy_sum(40, 2))', timeout=5)\n",
|
||||||
|
"])\n",
|
||||||
|
"\n",
|
||||||
|
"await executor.execute(helper_request)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "25f9718c",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Clean Up"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 7,
|
||||||
|
"id": "b09059f1",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"Cache directory removed ✅\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"shutil.rmtree(executor.cache_dir, ignore_errors=True)\n",
|
||||||
|
"print(\"Cache directory removed ✅\")"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "2c93cdef",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"## Package-manager detection & automatic bootstrap"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 8,
|
||||||
|
"id": "8691f3e3",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"from dapr_agents.executors.utils import package_manager as pm\n",
|
||||||
|
"import pathlib, tempfile"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "e9e08d81",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### Create a throw-away project"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 9,
|
||||||
|
"id": "4c7dd9c3",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"tmp project: /var/folders/9z/8xhqw8x1611fcbhzl339yrs40000gn/T/tmpmssk0m2b\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"tmp_proj = pathlib.Path(tempfile.mkdtemp())\n",
|
||||||
|
"(tmp_proj / \"requirements.txt\").write_text(\"rich==13.7.0\\n\")\n",
|
||||||
|
"print(\"tmp project:\", tmp_proj)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "03558a95",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### Show what the helper detects"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 10,
|
||||||
|
"id": "3b5acbfb",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"detect_package_managers -> [<PackageManagerType.PIP: 'pip'>]\n",
|
||||||
|
"get_install_command -> pip install -r requirements.txt\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"print(\"detect_package_managers ->\",\n",
|
||||||
|
" [m.name for m in pm.detect_package_managers(tmp_proj)])\n",
|
||||||
|
"print(\"get_install_command ->\",\n",
|
||||||
|
" pm.get_install_command(tmp_proj))"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "42f1ae7c",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### Point the executor at that directory"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 11,
|
||||||
|
"id": "81e53cf4",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"import os\n",
|
||||||
|
"from contextlib import contextmanager, ExitStack\n",
|
||||||
|
"\n",
|
||||||
|
"@contextmanager\n",
|
||||||
|
"def chdir(path):\n",
|
||||||
|
" \"\"\"\n",
|
||||||
|
" Temporarily change the process CWD to *path*.\n",
|
||||||
|
"\n",
|
||||||
|
" Works on every CPython ≥ 3.6 (and PyPy) and restores the old directory\n",
|
||||||
|
" even if an exception is raised inside the block.\n",
|
||||||
|
" \"\"\"\n",
|
||||||
|
" old_cwd = os.getcwd()\n",
|
||||||
|
" os.chdir(path)\n",
|
||||||
|
" try:\n",
|
||||||
|
" yield\n",
|
||||||
|
" finally:\n",
|
||||||
|
" os.chdir(old_cwd)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 12,
|
||||||
|
"id": "fb2f5052",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stderr",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"INFO:dapr_agents.executors.local:bootstrapping python project with 'pip install -r requirements.txt'\n",
|
||||||
|
"INFO:dapr_agents.executors.local:Sandbox backend enabled: seatbelt\n",
|
||||||
|
"INFO:dapr_agents.executors.local:Created a new virtual environment\n",
|
||||||
|
"INFO:dapr_agents.executors.local:Snippet 1 finished in 1.433s\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"text/html": [
|
||||||
|
"<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\">bootstrap OK\n",
|
||||||
|
"\n",
|
||||||
|
"</pre>\n"
|
||||||
|
],
|
||||||
|
"text/plain": [
|
||||||
|
"bootstrap OK\n",
|
||||||
|
"\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"metadata": {},
|
||||||
|
"output_type": "display_data"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"with ExitStack() as stack:\n",
|
||||||
|
" # keep a directory handle open (optional but handy if you’ll delete tmp_proj later)\n",
|
||||||
|
" stack.enter_context(os.scandir(tmp_proj))\n",
|
||||||
|
"\n",
|
||||||
|
" # <-- our portable replacement for contextlib.chdir()\n",
|
||||||
|
" stack.enter_context(chdir(tmp_proj))\n",
|
||||||
|
"\n",
|
||||||
|
" # run a trivial snippet; executor will bootstrap because it now “sees”\n",
|
||||||
|
" # requirements.txt in the current working directory\n",
|
||||||
|
" out = await executor.execute(\n",
|
||||||
|
" ExecutionRequest(snippets=[\n",
|
||||||
|
" CodeSnippet(language=\"python\", code=\"print('bootstrap OK')\", timeout=5)\n",
|
||||||
|
" ])\n",
|
||||||
|
" )\n",
|
||||||
|
" console.print(out[0].output)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "markdown",
|
||||||
|
"id": "45de2386",
|
||||||
|
"metadata": {},
|
||||||
|
"source": [
|
||||||
|
"### Clean Up the throw-away project "
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 13,
|
||||||
|
"id": "0c7aa010",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"Cache directory removed ✅\n",
|
||||||
|
"temporary project removed ✅\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"shutil.rmtree(executor.cache_dir, ignore_errors=True)\n",
|
||||||
|
"print(\"Cache directory removed ✅\")\n",
|
||||||
|
"shutil.rmtree(tmp_proj, ignore_errors=True)\n",
|
||||||
|
"print(\"temporary project removed ✅\")"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "36ea4010",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"kernelspec": {
|
||||||
|
"display_name": ".venv",
|
||||||
|
"language": "python",
|
||||||
|
"name": "python3"
|
||||||
|
},
|
||||||
|
"language_info": {
|
||||||
|
"codemirror_mode": {
|
||||||
|
"name": "ipython",
|
||||||
|
"version": 3
|
||||||
|
},
|
||||||
|
"file_extension": ".py",
|
||||||
|
"mimetype": "text/x-python",
|
||||||
|
"name": "python",
|
||||||
|
"nbconvert_exporter": "python",
|
||||||
|
"pygments_lexer": "ipython3",
|
||||||
|
"version": "3.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nbformat": 4,
|
||||||
|
"nbformat_minor": 5
|
||||||
|
}
|
|
@ -1,227 +1,331 @@
|
||||||
from dapr_agents.executors import CodeExecutorBase
|
"""Local executor that runs Python or shell snippets in cached virtual-envs."""
|
||||||
from dapr_agents.types.executor import ExecutionRequest, ExecutionResult
|
|
||||||
from typing import List, Union, Any, Callable
|
|
||||||
from pydantic import Field
|
|
||||||
from pathlib import Path
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import venv
|
import ast
|
||||||
import logging
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import inspect
|
import inspect
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
import ast
|
import venv
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, List, Sequence, Union
|
||||||
|
|
||||||
|
from pydantic import Field, PrivateAttr
|
||||||
|
|
||||||
|
from dapr_agents.executors import CodeExecutorBase
|
||||||
|
from dapr_agents.executors.sandbox import detect_backend, wrap_command, SandboxType
|
||||||
|
from dapr_agents.executors.utils.package_manager import get_install_command, get_project_type
|
||||||
|
from dapr_agents.types.executor import ExecutionRequest, ExecutionResult
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class LocalCodeExecutor(CodeExecutorBase):
|
|
||||||
"""Executes code locally in an optimized virtual environment with caching,
|
|
||||||
user-defined functions, and enhanced security.
|
|
||||||
|
|
||||||
Supports Python and shell execution with real-time logging,
|
class LocalCodeExecutor(CodeExecutorBase):
|
||||||
efficient dependency management, and reduced file I/O.
|
"""
|
||||||
|
Run snippets locally with **optional OS-level sandboxing** and
|
||||||
|
per-snippet virtual-env caching.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
cache_dir: Path = Field(default_factory=lambda: Path.cwd() / ".dapr_agents_cached_envs", description="Directory for cached virtual environments and execution artifacts.")
|
cache_dir: Path = Field(
|
||||||
user_functions: List[Callable] = Field(default_factory=list, description="List of user-defined functions available during execution.")
|
default_factory=lambda: Path.cwd() / ".dapr_agents_cached_envs",
|
||||||
cleanup_threshold: int = Field(default=604800, description="Time (in seconds) before cached virtual environments are considered for cleanup.")
|
description="Directory that stores cached virtual environments.",
|
||||||
|
)
|
||||||
|
user_functions: List[Callable] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Functions whose source is prepended to every Python snippet.",
|
||||||
|
)
|
||||||
|
sandbox: SandboxType = Field(
|
||||||
|
default="auto",
|
||||||
|
description="'seatbelt' | 'firejail' | 'none' | 'auto' (best available)",
|
||||||
|
)
|
||||||
|
writable_paths: List[Path] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Extra paths the sandboxed process may write to.",
|
||||||
|
)
|
||||||
|
cleanup_threshold: int = Field(
|
||||||
|
default=604_800, # one week
|
||||||
|
description="Seconds before a cached venv is considered stale.",
|
||||||
|
)
|
||||||
|
|
||||||
_env_lock = asyncio.Lock()
|
_env_lock: asyncio.Lock = PrivateAttr(default_factory=asyncio.Lock)
|
||||||
|
_bootstrapped_root: Path | None = PrivateAttr(default=None)
|
||||||
|
|
||||||
def model_post_init(self, __context: Any) -> None:
|
|
||||||
"""Ensures the cache directory is created after model initialization."""
|
def model_post_init(self, __context: Any) -> None: # noqa: D401
|
||||||
|
"""Create ``cache_dir`` after pydantic instantiation."""
|
||||||
super().model_post_init(__context)
|
super().model_post_init(__context)
|
||||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
logger.info("Cache directory set.")
|
logger.debug("venv cache directory: %s", self.cache_dir)
|
||||||
logger.debug(f"{self.cache_dir}")
|
|
||||||
|
|
||||||
async def execute(self, request: Union[ExecutionRequest, dict]) -> List[ExecutionResult]:
|
async def execute(
|
||||||
"""Executes Python or shell code securely in a persistent virtual environment with caching and real-time logging.
|
self, request: Union[ExecutionRequest, dict]
|
||||||
|
) -> List[ExecutionResult]:
|
||||||
|
"""
|
||||||
|
Run the snippets in *request* and return their results.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request (Union[ExecutionRequest, dict]): The execution request containing code snippets.
|
request: ``ExecutionRequest`` instance or a raw mapping that can
|
||||||
|
be unpacked into one.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List[ExecutionResult]: A list of execution results for each snippet.
|
A list with one ``ExecutionResult`` for every snippet in the
|
||||||
|
original request.
|
||||||
"""
|
"""
|
||||||
if isinstance(request, dict):
|
if isinstance(request, dict):
|
||||||
request = ExecutionRequest(**request)
|
request = ExecutionRequest(**request)
|
||||||
|
|
||||||
|
await self._bootstrap_project()
|
||||||
self.validate_snippets(request.snippets)
|
self.validate_snippets(request.snippets)
|
||||||
results = []
|
|
||||||
|
|
||||||
for snippet in request.snippets:
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
|
# Resolve sandbox once
|
||||||
|
eff_backend: SandboxType = (
|
||||||
|
detect_backend() if self.sandbox == "auto" else self.sandbox
|
||||||
|
)
|
||||||
|
if eff_backend != "none":
|
||||||
|
logger.info(
|
||||||
|
"Sandbox backend enabled: %s%s",
|
||||||
|
eff_backend,
|
||||||
|
f" (writable: {', '.join(map(str, self.writable_paths))})"
|
||||||
|
if self.writable_paths
|
||||||
|
else "",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("Sandbox disabled - running commands directly.")
|
||||||
|
|
||||||
|
# Main loop
|
||||||
|
results: list[ExecutionResult] = []
|
||||||
|
for snip_idx, snippet in enumerate(request.snippets, start=1):
|
||||||
|
start = time.perf_counter()
|
||||||
|
|
||||||
|
# Assemble the *raw* command
|
||||||
if snippet.language == "python":
|
if snippet.language == "python":
|
||||||
required_packages = self._extract_imports(snippet.code)
|
env = await self._prepare_python_env(snippet.code)
|
||||||
logger.info(f"Packages Required: {required_packages}")
|
python_bin = env / "bin" / "python3"
|
||||||
venv_path = await self._get_or_create_cached_env(required_packages)
|
prelude = "\n".join(inspect.getsource(fn) for fn in self.user_functions)
|
||||||
|
script = f"{prelude}\n{snippet.code}" if prelude else snippet.code
|
||||||
# Load user-defined functions dynamically in memory
|
raw_cmd: Sequence[str] = [str(python_bin), "-c", script]
|
||||||
function_code = "\n".join(inspect.getsource(f) for f in self.user_functions) if self.user_functions else ""
|
|
||||||
exec_script = f"{function_code}\n{snippet.code}" if function_code else snippet.code
|
|
||||||
|
|
||||||
python_executable = venv_path / "bin" / "python3"
|
|
||||||
command = [str(python_executable), "-c", exec_script]
|
|
||||||
else:
|
else:
|
||||||
command = ["sh", "-c", snippet.code]
|
raw_cmd = ["sh", "-c", snippet.code]
|
||||||
|
|
||||||
logger.info("Executing command")
|
# Wrap for sandbox
|
||||||
logger.debug(f"{' '.join(command)}")
|
final_cmd = wrap_command(raw_cmd, eff_backend, self.writable_paths)
|
||||||
|
logger.debug(
|
||||||
|
"Snippet %s - launch command: %s",
|
||||||
|
snip_idx,
|
||||||
|
" ".join(final_cmd),
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
# Run it
|
||||||
# Start subprocess execution with explicit timeout
|
snip_timeout = getattr(snippet, "timeout", request.timeout)
|
||||||
process = await asyncio.create_subprocess_exec(
|
results.append(await self._run_subprocess(final_cmd, snip_timeout))
|
||||||
*command,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
close_fds=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wait for completion with timeout enforcement
|
logger.info(
|
||||||
stdout_output, stderr_output = await asyncio.wait_for(process.communicate(), timeout=request.timeout)
|
"Snippet %s finished in %.3fs",
|
||||||
|
snip_idx,
|
||||||
status = "success" if process.returncode == 0 else "error"
|
time.perf_counter() - start,
|
||||||
execution_time = time.time() - start_time
|
)
|
||||||
|
|
||||||
logger.info(f"Execution completed in {execution_time:.2f} seconds.")
|
|
||||||
if stderr_output:
|
|
||||||
logger.error(f"STDERR: {stderr_output.decode()}")
|
|
||||||
|
|
||||||
results.append(ExecutionResult(
|
|
||||||
status=status,
|
|
||||||
output=stdout_output.decode(),
|
|
||||||
exit_code=process.returncode
|
|
||||||
))
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
process.terminate() # Ensure subprocess is killed if it times out
|
|
||||||
results.append(ExecutionResult(status="error", output="Execution timed out", exit_code=1))
|
|
||||||
except Exception as e:
|
|
||||||
results.append(ExecutionResult(status="error", output=str(e), exit_code=1))
|
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def _extract_imports(self, code: str) -> List[str]:
|
async def _bootstrap_project(self) -> None:
|
||||||
"""Parses a Python script and extracts top-level module imports.
|
"""Install top-level dependencies once per executor instance."""
|
||||||
|
cwd = Path.cwd().resolve()
|
||||||
|
if self._bootstrapped_root == cwd:
|
||||||
|
return
|
||||||
|
|
||||||
|
install_cmd = get_install_command(str(cwd))
|
||||||
|
if install_cmd:
|
||||||
|
logger.info(
|
||||||
|
"bootstrapping %s project with '%s'",
|
||||||
|
get_project_type(str(cwd)).value,
|
||||||
|
install_cmd
|
||||||
|
)
|
||||||
|
|
||||||
|
proc = await asyncio.create_subprocess_shell(
|
||||||
|
install_cmd,
|
||||||
|
cwd=cwd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
_, err = await proc.communicate()
|
||||||
|
if proc.returncode:
|
||||||
|
logger.warning(
|
||||||
|
"bootstrap failed (%d): %s",
|
||||||
|
proc.returncode,
|
||||||
|
err.decode().strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
self._bootstrapped_root = cwd
|
||||||
|
|
||||||
|
async def _prepare_python_env(self, code: str) -> Path:
|
||||||
|
"""
|
||||||
|
Ensure a virtual-env exists that satisfies *code* imports.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
code (str): The Python code snippet to analyze.
|
code: User-supplied Python source.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List[str]: A list of imported module names found in the code.
|
Path to the virtual-env directory.
|
||||||
|
"""
|
||||||
|
imports = self._extract_imports(code)
|
||||||
|
env = await self._get_or_create_cached_env(imports)
|
||||||
|
missing = await self._get_missing_packages(imports, env)
|
||||||
|
if missing:
|
||||||
|
await self._install_missing_packages(missing, env)
|
||||||
|
return env
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_imports(code: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
Return all top-level imported module names in *code*.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: Python source to scan.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Unique list of first-segment module names.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
SyntaxError: If the code has invalid syntax and cannot be parsed.
|
SyntaxError: If *code* cannot be parsed.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
parsed_code = ast.parse(code)
|
tree = ast.parse(code)
|
||||||
except SyntaxError as e:
|
except SyntaxError:
|
||||||
logger.error(f"Syntax error while parsing code: {e}")
|
logger.error("cannot parse user code, assuming no imports")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
modules = set()
|
names = {
|
||||||
for node in ast.walk(parsed_code):
|
alias.name.partition('.')[0]
|
||||||
if isinstance(node, ast.Import):
|
for node in ast.walk(tree)
|
||||||
for alias in node.names:
|
for alias in getattr(node, "names", [])
|
||||||
modules.add(alias.name.split('.')[0]) # Get the top-level package
|
if isinstance(node, (ast.Import, ast.ImportFrom))
|
||||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
}
|
||||||
modules.add(node.module.split('.')[0])
|
if any(isinstance(node, ast.ImportFrom) and node.module
|
||||||
|
for node in ast.walk(tree)):
|
||||||
|
names |= {
|
||||||
|
node.module.partition('.')[0]
|
||||||
|
for node in ast.walk(tree)
|
||||||
|
if isinstance(node, ast.ImportFrom) and node.module
|
||||||
|
}
|
||||||
|
return sorted(names)
|
||||||
|
|
||||||
return list(modules)
|
async def _get_missing_packages(
|
||||||
|
self, packages: List[str], env_path: Path
|
||||||
async def _get_missing_packages(self, packages: List[str], env_path: Path) -> List[str]:
|
) -> List[str]:
|
||||||
"""Determines which packages are missing inside a given virtual environment.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
packages (List[str]): A list of package names to check.
|
|
||||||
env_path (Path): Path to the virtual environment.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List[str]: A list of packages that are missing from the virtual environment.
|
|
||||||
"""
|
"""
|
||||||
python_bin = env_path / "bin" / "python3"
|
Identify which *packages* are not importable from *env_path*.
|
||||||
|
|
||||||
async def check_package(pkg):
|
|
||||||
process = await asyncio.create_subprocess_exec(
|
|
||||||
str(python_bin), "-c", f"import {pkg}",
|
|
||||||
stdout=asyncio.subprocess.DEVNULL,
|
|
||||||
stderr=asyncio.subprocess.DEVNULL
|
|
||||||
)
|
|
||||||
await process.wait()
|
|
||||||
return pkg if process.returncode != 0 else None # Return package name if missing
|
|
||||||
|
|
||||||
tasks = [check_package(pkg) for pkg in packages]
|
|
||||||
results = await asyncio.gather(*tasks)
|
|
||||||
|
|
||||||
return [pkg for pkg in results if pkg] # Filter out installed packages
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_or_create_cached_env(self, dependencies: List[str]) -> Path:
|
|
||||||
"""Creates or retrieves a cached virtual environment based on dependencies.
|
|
||||||
|
|
||||||
This function checks if a suitable cached virtual environment exists.
|
|
||||||
If it does not, it creates a new one and installs missing dependencies.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
dependencies (List[str]): List of required package names.
|
packages: Candidate import names.
|
||||||
|
env_path: Path to the virtual-env.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path: Path to the virtual environment directory.
|
Subset of *packages* that need installation.
|
||||||
|
"""
|
||||||
|
python = env_path / "bin" / "python3"
|
||||||
|
|
||||||
|
async def probe(pkg: str) -> str | None:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
str(python),
|
||||||
|
"- <<PY\nimport importlib.util, sys;"
|
||||||
|
f"sys.exit(importlib.util.find_spec('{pkg}') is None)\nPY",
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
await proc.wait()
|
||||||
|
return pkg if proc.returncode else None
|
||||||
|
|
||||||
|
missing = await asyncio.gather(*(probe(p) for p in packages))
|
||||||
|
return [m for m in missing if m]
|
||||||
|
|
||||||
|
async def _get_or_create_cached_env(self, deps: List[str]) -> Path:
|
||||||
|
"""
|
||||||
|
Return a cached venv path keyed by the sorted list *deps*.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
deps: Import names required by user code.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the virtual-env directory.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If virtual environment creation or package installation fails.
|
RuntimeError: If venv creation fails.
|
||||||
"""
|
"""
|
||||||
async with self._env_lock:
|
digest = hashlib.sha1(",".join(sorted(deps)).encode()).hexdigest()
|
||||||
env_hash = hashlib.md5(",".join(sorted(dependencies)).encode()).hexdigest()
|
env_path = self.cache_dir / f"env_{digest}"
|
||||||
env_path = self.cache_dir / f"env_{env_hash}"
|
|
||||||
|
|
||||||
|
async with self._env_lock:
|
||||||
if env_path.exists():
|
if env_path.exists():
|
||||||
logger.info("Reusing cached virtual environment.")
|
logger.info("Reusing cached virtual environment.")
|
||||||
else:
|
else:
|
||||||
logger.info("Setting up a new virtual environment.")
|
|
||||||
try:
|
try:
|
||||||
venv.create(str(env_path), with_pip=True)
|
venv.create(env_path, with_pip=True)
|
||||||
except Exception as e:
|
logger.info("Created a new virtual environment")
|
||||||
logger.error(f"Failed to create virtual environment: {e}")
|
logger.debug("venv %s created", env_path)
|
||||||
raise RuntimeError(f"Virtual environment creation failed: {e}")
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise RuntimeError("virtual-env creation failed") from exc
|
||||||
|
return env_path
|
||||||
|
|
||||||
# Identify missing packages
|
async def _install_missing_packages(
|
||||||
missing_packages = await self._get_missing_packages(dependencies, env_path)
|
self, packages: List[str], env_dir: Path
|
||||||
|
) -> None:
|
||||||
if missing_packages:
|
"""
|
||||||
await self._install_missing_packages(missing_packages, env_path)
|
``pip install`` *packages* inside *env_dir*.
|
||||||
|
|
||||||
return env_path
|
|
||||||
|
|
||||||
|
|
||||||
async def _install_missing_packages(self, packages: List[str], env_dir: Path):
|
|
||||||
"""Installs missing Python packages inside the virtual environment.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
packages (List[str]): A list of package names to install.
|
packages: Package names to install.
|
||||||
env_dir (Path): Path to the virtual environment where packages should be installed.
|
env_dir: Target virtual-env directory.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If the package installation process fails.
|
RuntimeError: If installation returns non-zero exit code.
|
||||||
"""
|
"""
|
||||||
if not packages:
|
python = env_dir / "bin" / "python3"
|
||||||
return
|
cmd = [str(python), "-m", "pip", "install", *packages]
|
||||||
|
logger.info("Installing %s", ", ".join(packages))
|
||||||
|
|
||||||
python_bin = env_dir / "bin" / "python3"
|
proc = await asyncio.create_subprocess_exec(
|
||||||
command = [str(python_bin), "-m", "pip", "install", *packages]
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
process = await asyncio.create_subprocess_exec(
|
|
||||||
*command,
|
|
||||||
stdout=asyncio.subprocess.DEVNULL, # Suppresses stdout since it's not used
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
close_fds=True
|
|
||||||
)
|
)
|
||||||
_, stderr = await process.communicate() # Capture only stderr
|
_, err = await proc.communicate()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
msg = err.decode().strip()
|
||||||
|
logger.error("pip install failed: %s", msg)
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
logger.debug("Installed %d package(s)", len(packages))
|
||||||
|
|
||||||
if process.returncode != 0:
|
async def _run_subprocess(self, cmd: Sequence[str], timeout: int) -> ExecutionResult:
|
||||||
error_msg = stderr.decode().strip()
|
"""
|
||||||
logger.error(f"Package installation failed: {error_msg}")
|
Run *cmd* with *timeout* seconds.
|
||||||
raise RuntimeError(f"Package installation failed: {error_msg}")
|
|
||||||
|
|
||||||
logger.info(f"Installed dependencies: {', '.join(packages)}")
|
Args:
|
||||||
|
cmd: Command list to execute.
|
||||||
|
timeout: Maximum runtime in seconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``ExecutionResult`` with captured output.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE
|
||||||
|
)
|
||||||
|
out, err = await asyncio.wait_for(proc.communicate(), timeout)
|
||||||
|
status = "success" if proc.returncode == 0 else "error"
|
||||||
|
if err:
|
||||||
|
logger.debug("stderr: %s", err.decode().strip())
|
||||||
|
return ExecutionResult(
|
||||||
|
status=status,
|
||||||
|
output=out.decode(),
|
||||||
|
exit_code=proc.returncode
|
||||||
|
)
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
proc.kill()
|
||||||
|
await proc.wait()
|
||||||
|
return ExecutionResult(status="error", output="execution timed out", exit_code=1)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
return ExecutionResult(status="error", output=str(exc), exit_code=1)
|
|
@ -0,0 +1,199 @@
|
||||||
|
"""Light-weight cross-platform sandbox helpers."""
|
||||||
|
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Literal, Sequence
|
||||||
|
|
||||||
|
SandboxType = Literal["none", "seatbelt", "firejail", "auto"]
|
||||||
|
|
||||||
|
_READ_ONLY_SEATBELT_POLICY = r"""
|
||||||
|
(version 1)
|
||||||
|
|
||||||
|
; ---------------- default = deny everything -----------------
|
||||||
|
(deny default)
|
||||||
|
|
||||||
|
; ---------------- read-only FS access -----------------------
|
||||||
|
(allow file-read*)
|
||||||
|
|
||||||
|
; ---------------- minimal process mgmt ----------------------
|
||||||
|
(allow process-exec)
|
||||||
|
(allow process-fork)
|
||||||
|
(allow signal (target self))
|
||||||
|
|
||||||
|
; ---------------- write-only to /dev/null -------------------
|
||||||
|
(allow file-write-data
|
||||||
|
(require-all
|
||||||
|
(path "/dev/null")
|
||||||
|
(vnode-type CHARACTER-DEVICE)))
|
||||||
|
|
||||||
|
; ---------------- harmless sysctls --------------------------
|
||||||
|
(allow sysctl-read
|
||||||
|
(sysctl-name "hw.activecpu")
|
||||||
|
(sysctl-name "hw.busfrequency_compat")
|
||||||
|
(sysctl-name "hw.byteorder")
|
||||||
|
(sysctl-name "hw.cacheconfig")
|
||||||
|
(sysctl-name "hw.cachelinesize_compat")
|
||||||
|
(sysctl-name "hw.cpufamily")
|
||||||
|
(sysctl-name "hw.cpufrequency_compat")
|
||||||
|
(sysctl-name "hw.cputype")
|
||||||
|
(sysctl-name "hw.l1dcachesize_compat")
|
||||||
|
(sysctl-name "hw.l1icachesize_compat")
|
||||||
|
(sysctl-name "hw.l2cachesize_compat")
|
||||||
|
(sysctl-name "hw.l3cachesize_compat")
|
||||||
|
(sysctl-name "hw.logicalcpu_max")
|
||||||
|
(sysctl-name "hw.machine")
|
||||||
|
(sysctl-name "hw.ncpu")
|
||||||
|
(sysctl-name "hw.nperflevels")
|
||||||
|
(sysctl-name "hw.memsize")
|
||||||
|
(sysctl-name "hw.pagesize")
|
||||||
|
(sysctl-name "hw.packages")
|
||||||
|
(sysctl-name "hw.physicalcpu_max")
|
||||||
|
(sysctl-name "kern.hostname")
|
||||||
|
(sysctl-name "kern.osrelease")
|
||||||
|
(sysctl-name "kern.ostype")
|
||||||
|
(sysctl-name "kern.osversion")
|
||||||
|
(sysctl-name "kern.version")
|
||||||
|
(sysctl-name-prefix "hw.perflevel")
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def detect_backend() -> SandboxType: # noqa: D401
|
||||||
|
"""Return the best-effort sandbox backend for the current host."""
|
||||||
|
system = platform.system()
|
||||||
|
if system == "Darwin" and shutil.which("sandbox-exec"):
|
||||||
|
return "seatbelt"
|
||||||
|
if system == "Linux" and shutil.which("firejail"):
|
||||||
|
return "firejail"
|
||||||
|
return "none"
|
||||||
|
|
||||||
|
|
||||||
|
def _seatbelt_cmd(cmd: Sequence[str], writable_paths: List[Path]) -> List[str]:
|
||||||
|
"""
|
||||||
|
Construct a **macOS seatbelt** command line.
|
||||||
|
|
||||||
|
The resulting list can be passed directly to `asyncio.create_subprocess_exec`.
|
||||||
|
It launches the target *cmd* under **sandbox-exec** with an
|
||||||
|
*initially-read-only* profile; every directory in *writable_paths* is added
|
||||||
|
as an explicit “write-allowed sub-path”.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cmd:
|
||||||
|
The *raw* command (program + args) that should run inside the sandbox.
|
||||||
|
writable_paths:
|
||||||
|
Absolute paths that the child process must be able to modify
|
||||||
|
(e.g. a temporary working directory).
|
||||||
|
Each entry becomes a param `-D WR<i>=<path>` and a corresponding
|
||||||
|
``file-write*`` rule in the generated profile.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[str]
|
||||||
|
A fully-assembled ``sandbox-exec`` invocation:
|
||||||
|
``['sandbox-exec', '-p', <profile>, …, '--', *cmd]``.
|
||||||
|
"""
|
||||||
|
policy = _READ_ONLY_SEATBELT_POLICY
|
||||||
|
params: list[str] = []
|
||||||
|
|
||||||
|
if writable_paths:
|
||||||
|
# Build parameter substitutions and the matching `(allow file-write*)` stanza.
|
||||||
|
write_terms: list[str] = []
|
||||||
|
for idx, path in enumerate(writable_paths):
|
||||||
|
param = f"WR{idx}"
|
||||||
|
params.extend(["-D", f"{param}={path}"])
|
||||||
|
write_terms.append(f'(subpath (param "{param}"))')
|
||||||
|
|
||||||
|
policy += f"\n(allow file-write*\n {' '.join(write_terms)}\n)"
|
||||||
|
|
||||||
|
return [
|
||||||
|
"sandbox-exec",
|
||||||
|
"-p",
|
||||||
|
policy,
|
||||||
|
*params,
|
||||||
|
"--",
|
||||||
|
*cmd,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _firejail_cmd(cmd: Sequence[str], writable_paths: List[Path]) -> List[str]:
|
||||||
|
"""
|
||||||
|
Build a **Firejail** command line (Linux only).
|
||||||
|
|
||||||
|
The wrapper enables seccomp, disables sound and networking, and whitelists
|
||||||
|
the provided *writable_paths* so the child process can persist data there.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cmd:
|
||||||
|
The command (program + args) to execute.
|
||||||
|
writable_paths:
|
||||||
|
Directories that must remain writable inside the Firejail sandbox.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[str]
|
||||||
|
A Firejail-prefixed command suitable for
|
||||||
|
``asyncio.create_subprocess_exec``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError
|
||||||
|
If *writable_paths* contains non-absolute paths.
|
||||||
|
"""
|
||||||
|
for p in writable_paths:
|
||||||
|
if not p.is_absolute():
|
||||||
|
raise ValueError(f"Firejail whitelist paths must be absolute: {p}")
|
||||||
|
|
||||||
|
rw_flags = sum([["--whitelist", str(p)] for p in writable_paths], [])
|
||||||
|
return [
|
||||||
|
"firejail",
|
||||||
|
"--quiet", # suppress banner
|
||||||
|
"--seccomp", # enable seccomp filter
|
||||||
|
"--nosound",
|
||||||
|
"--net=none",
|
||||||
|
*rw_flags,
|
||||||
|
"--",
|
||||||
|
*cmd,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_command(
|
||||||
|
cmd: Sequence[str],
|
||||||
|
backend: SandboxType,
|
||||||
|
writable_paths: List[Path] | None = None,
|
||||||
|
) -> List[str]:
|
||||||
|
"""
|
||||||
|
Produce a sandbox-wrapped command according to *backend*.
|
||||||
|
|
||||||
|
This is the single public helper used by the executors: it hides the
|
||||||
|
platform-specific details of **seatbelt** and **Firejail** while providing
|
||||||
|
a graceful fallback to “no sandbox”.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cmd:
|
||||||
|
The raw command (program + args) to execute.
|
||||||
|
backend:
|
||||||
|
One of ``'seatbelt'``, ``'firejail'``, ``'none'`` or ``'auto'``.
|
||||||
|
When ``'auto'`` is supplied the caller should already have resolved the
|
||||||
|
platform with :func:`detect_backend`; the value is treated as ``'none'``.
|
||||||
|
writable_paths:
|
||||||
|
Extra directories that must remain writable inside the sandbox.
|
||||||
|
Ignored when *backend* is ``'none'`` / ``'auto'``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[str]
|
||||||
|
The command list ready for ``asyncio.create_subprocess_exec``.
|
||||||
|
If sandboxing is disabled, this is simply ``list(cmd)``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError
|
||||||
|
If an unrecognised *backend* value is given.
|
||||||
|
"""
|
||||||
|
if backend in ("none", "auto"):
|
||||||
|
return list(cmd)
|
||||||
|
|
||||||
|
writable_paths = writable_paths or []
|
||||||
|
|
||||||
|
if backend == "seatbelt":
|
||||||
|
return _seatbelt_cmd(cmd, writable_paths)
|
||||||
|
|
||||||
|
if backend == "firejail":
|
||||||
|
return _firejail_cmd(cmd, writable_paths)
|
||||||
|
|
||||||
|
raise ValueError(f"Unknown sandbox backend: {backend!r}")
|
|
@ -0,0 +1,303 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional, Set
|
||||||
|
from functools import lru_cache
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class PackageManagerType(str, Enum):
|
||||||
|
"""Types of package managers that can be detected."""
|
||||||
|
PIP = "pip"
|
||||||
|
POETRY = "poetry"
|
||||||
|
PIPENV = "pipenv"
|
||||||
|
CONDA = "conda"
|
||||||
|
NPM = "npm"
|
||||||
|
YARN = "yarn"
|
||||||
|
PNPM = "pnpm"
|
||||||
|
BUN = "bun"
|
||||||
|
CARGO = "cargo"
|
||||||
|
GO = "go"
|
||||||
|
MAVEN = "maven"
|
||||||
|
GRADLE = "gradle"
|
||||||
|
COMPOSER = "composer"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectType(str, Enum):
|
||||||
|
"""Types of projects that can be detected."""
|
||||||
|
PYTHON = "python"
|
||||||
|
NODE = "node"
|
||||||
|
RUST = "rust"
|
||||||
|
GO = "go"
|
||||||
|
JAVA = "java"
|
||||||
|
PHP = "php"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class PackageManager:
|
||||||
|
"""Information about a package manager and its commands."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: PackageManagerType,
|
||||||
|
project_type: ProjectType,
|
||||||
|
install_cmd: str,
|
||||||
|
add_cmd: Optional[str] = None,
|
||||||
|
remove_cmd: Optional[str] = None,
|
||||||
|
update_cmd: Optional[str] = None,
|
||||||
|
markers: Optional[List[str]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Initialize a package manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Package manager identifier.
|
||||||
|
project_type: Type of project this manager serves.
|
||||||
|
install_cmd: Command to install project dependencies.
|
||||||
|
add_cmd: Command to add a single package.
|
||||||
|
remove_cmd: Command to remove a package.
|
||||||
|
update_cmd: Command to update packages.
|
||||||
|
markers: Filenames indicating this manager in a project.
|
||||||
|
"""
|
||||||
|
self.name = name
|
||||||
|
self.project_type = project_type
|
||||||
|
self.install_cmd = install_cmd
|
||||||
|
self.add_cmd = add_cmd or f"{name.value} install"
|
||||||
|
self.remove_cmd = remove_cmd or f"{name.value} remove"
|
||||||
|
self.update_cmd = update_cmd or f"{name.value} update"
|
||||||
|
self.markers = markers or []
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name.value
|
||||||
|
|
||||||
|
|
||||||
|
# Known package managers
|
||||||
|
PACKAGE_MANAGERS: dict[PackageManagerType, PackageManager] = {
|
||||||
|
# Python package managers
|
||||||
|
PackageManagerType.PIP: PackageManager(
|
||||||
|
name=PackageManagerType.PIP,
|
||||||
|
project_type=ProjectType.PYTHON,
|
||||||
|
install_cmd="pip install -r requirements.txt",
|
||||||
|
add_cmd="pip install",
|
||||||
|
remove_cmd="pip uninstall",
|
||||||
|
update_cmd="pip install --upgrade",
|
||||||
|
markers=["requirements.txt", "setup.py", "setup.cfg"]
|
||||||
|
),
|
||||||
|
PackageManagerType.POETRY: PackageManager(
|
||||||
|
name=PackageManagerType.POETRY,
|
||||||
|
project_type=ProjectType.PYTHON,
|
||||||
|
install_cmd="poetry install",
|
||||||
|
add_cmd="poetry add",
|
||||||
|
remove_cmd="poetry remove",
|
||||||
|
update_cmd="poetry update",
|
||||||
|
markers=["pyproject.toml", "poetry.lock"]
|
||||||
|
),
|
||||||
|
PackageManagerType.PIPENV: PackageManager(
|
||||||
|
name=PackageManagerType.PIPENV,
|
||||||
|
project_type=ProjectType.PYTHON,
|
||||||
|
install_cmd="pipenv install",
|
||||||
|
add_cmd="pipenv install",
|
||||||
|
remove_cmd="pipenv uninstall",
|
||||||
|
update_cmd="pipenv update",
|
||||||
|
markers=["Pipfile", "Pipfile.lock"]
|
||||||
|
),
|
||||||
|
PackageManagerType.CONDA: PackageManager(
|
||||||
|
name=PackageManagerType.CONDA,
|
||||||
|
project_type=ProjectType.PYTHON,
|
||||||
|
install_cmd="conda env update -f environment.yml",
|
||||||
|
add_cmd="conda install",
|
||||||
|
remove_cmd="conda remove",
|
||||||
|
update_cmd="conda update",
|
||||||
|
markers=["environment.yml", "environment.yaml"]
|
||||||
|
),
|
||||||
|
# JavaScript package managers
|
||||||
|
PackageManagerType.NPM: PackageManager(
|
||||||
|
name=PackageManagerType.NPM,
|
||||||
|
project_type=ProjectType.NODE,
|
||||||
|
install_cmd="npm install",
|
||||||
|
add_cmd="npm install",
|
||||||
|
remove_cmd="npm uninstall",
|
||||||
|
update_cmd="npm update",
|
||||||
|
markers=["package.json", "package-lock.json"]
|
||||||
|
),
|
||||||
|
PackageManagerType.YARN: PackageManager(
|
||||||
|
name=PackageManagerType.YARN,
|
||||||
|
project_type=ProjectType.NODE,
|
||||||
|
install_cmd="yarn install",
|
||||||
|
add_cmd="yarn add",
|
||||||
|
remove_cmd="yarn remove",
|
||||||
|
update_cmd="yarn upgrade",
|
||||||
|
markers=["package.json", "yarn.lock"]
|
||||||
|
),
|
||||||
|
PackageManagerType.PNPM: PackageManager(
|
||||||
|
name=PackageManagerType.PNPM,
|
||||||
|
project_type=ProjectType.NODE,
|
||||||
|
install_cmd="pnpm install",
|
||||||
|
add_cmd="pnpm add",
|
||||||
|
remove_cmd="pnpm remove",
|
||||||
|
update_cmd="pnpm update",
|
||||||
|
markers=["package.json", "pnpm-lock.yaml"]
|
||||||
|
),
|
||||||
|
PackageManagerType.BUN: PackageManager(
|
||||||
|
name=PackageManagerType.BUN,
|
||||||
|
project_type=ProjectType.NODE,
|
||||||
|
install_cmd="bun install",
|
||||||
|
add_cmd="bun add",
|
||||||
|
remove_cmd="bun remove",
|
||||||
|
update_cmd="bun update",
|
||||||
|
markers=["package.json", "bun.lockb"]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=None)
|
||||||
|
def is_installed(name: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a given command exists on PATH.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Command name to check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the command is available, False otherwise.
|
||||||
|
"""
|
||||||
|
return shutil.which(name) is not None
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=None)
|
||||||
|
def detect_package_managers(directory: str) -> List[PackageManager]:
|
||||||
|
"""
|
||||||
|
Detect all installed package managers by looking for marker files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Path to the project root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of PackageManager instances found in the directory.
|
||||||
|
"""
|
||||||
|
dir_path = Path(directory)
|
||||||
|
if not dir_path.is_dir():
|
||||||
|
return []
|
||||||
|
|
||||||
|
found: List[PackageManager] = []
|
||||||
|
for pm in PACKAGE_MANAGERS.values():
|
||||||
|
for marker in pm.markers:
|
||||||
|
if (dir_path / marker).exists() and is_installed(pm.name.value):
|
||||||
|
found.append(pm)
|
||||||
|
break
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=None)
|
||||||
|
def get_primary_package_manager(directory: str) -> Optional[PackageManager]:
|
||||||
|
"""
|
||||||
|
Determine the primary package manager using lockfile heuristics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Path to the project root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The chosen PackageManager or None if none detected.
|
||||||
|
"""
|
||||||
|
managers = detect_package_managers(directory)
|
||||||
|
if not managers:
|
||||||
|
return None
|
||||||
|
if len(managers) == 1:
|
||||||
|
return managers[0]
|
||||||
|
|
||||||
|
dir_path = Path(directory)
|
||||||
|
# Prefer lockfiles over others
|
||||||
|
lock_priority = [
|
||||||
|
(PackageManagerType.POETRY, "poetry.lock"),
|
||||||
|
(PackageManagerType.PIPENV, "Pipfile.lock"),
|
||||||
|
(PackageManagerType.PNPM, "pnpm-lock.yaml"),
|
||||||
|
(PackageManagerType.YARN, "yarn.lock"),
|
||||||
|
(PackageManagerType.BUN, "bun.lockb"),
|
||||||
|
(PackageManagerType.NPM, "package-lock.json"),
|
||||||
|
]
|
||||||
|
for pm_type, lock in lock_priority:
|
||||||
|
if (dir_path / lock).exists() and PACKAGE_MANAGERS.get(pm_type) in managers:
|
||||||
|
return PACKAGE_MANAGERS[pm_type]
|
||||||
|
|
||||||
|
return managers[0]
|
||||||
|
|
||||||
|
|
||||||
|
def get_install_command(directory: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get the shell command to install project dependencies.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Path to the project root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A shell command string or None if no manager detected.
|
||||||
|
"""
|
||||||
|
pm = get_primary_package_manager(directory)
|
||||||
|
return pm.install_cmd if pm else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_add_command(directory: str, package: str, dev: bool = False) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get the shell command to add a package to the project.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Path to the project root.
|
||||||
|
package: Package name to add.
|
||||||
|
dev: Whether to add as a development dependency.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A shell command string or None if no manager detected.
|
||||||
|
"""
|
||||||
|
pm = get_primary_package_manager(directory)
|
||||||
|
if not pm:
|
||||||
|
return None
|
||||||
|
base = pm.add_cmd
|
||||||
|
if dev and pm.name in {
|
||||||
|
PackageManagerType.PIP,
|
||||||
|
PackageManagerType.POETRY,
|
||||||
|
PackageManagerType.NPM,
|
||||||
|
PackageManagerType.YARN,
|
||||||
|
PackageManagerType.PNPM,
|
||||||
|
PackageManagerType.BUN,
|
||||||
|
PackageManagerType.COMPOSER,
|
||||||
|
}:
|
||||||
|
flag = "--dev" if pm.name in {PackageManagerType.PIP, PackageManagerType.POETRY} else "--save-dev"
|
||||||
|
return f"{base} {package} {flag}"
|
||||||
|
return f"{base} {package}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_project_type(directory: str) -> ProjectType:
|
||||||
|
"""
|
||||||
|
Infer project type from the primary package manager or file extensions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Path to the project root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The detected ProjectType.
|
||||||
|
"""
|
||||||
|
pm = get_primary_package_manager(directory)
|
||||||
|
if pm:
|
||||||
|
return pm.project_type
|
||||||
|
|
||||||
|
# Fallback by extension scanning
|
||||||
|
exts: Set[str] = set()
|
||||||
|
for path in Path(directory).rglob('*'):
|
||||||
|
if path.is_file():
|
||||||
|
exts.add(path.suffix.lower())
|
||||||
|
if len(exts) > 50:
|
||||||
|
break
|
||||||
|
if '.py' in exts:
|
||||||
|
return ProjectType.PYTHON
|
||||||
|
if {'.js', '.ts'} & exts:
|
||||||
|
return ProjectType.NODE
|
||||||
|
if '.rs' in exts:
|
||||||
|
return ProjectType.RUST
|
||||||
|
if '.go' in exts:
|
||||||
|
return ProjectType.GO
|
||||||
|
if '.java' in exts:
|
||||||
|
return ProjectType.JAVA
|
||||||
|
if '.php' in exts:
|
||||||
|
return ProjectType.PHP
|
||||||
|
return ProjectType.UNKNOWN
|
|
@ -13,6 +13,7 @@ class CodeSnippet(BaseModel):
|
||||||
|
|
||||||
language: str = Field(..., description="The programming language of the code snippet (e.g., 'python', 'javascript').")
|
language: str = Field(..., description="The programming language of the code snippet (e.g., 'python', 'javascript').")
|
||||||
code: str = Field(..., description="The actual source code to be executed.")
|
code: str = Field(..., description="The actual source code to be executed.")
|
||||||
|
timeout: int = Field(5, description="Per-snippet timeout (seconds). Executor falls back to the request-level timeout if omitted.")
|
||||||
|
|
||||||
class ExecutionRequest(BaseModel):
|
class ExecutionRequest(BaseModel):
|
||||||
"""Represents a request to execute a code snippet."""
|
"""Represents a request to execute a code snippet."""
|
||||||
|
|
Loading…
Reference in New Issue