Spaces:
Sleeping
Sleeping
Commit ·
a89f25d
1
Parent(s): ec9973e
Deploy Code Execution Sandbox with FastAPI and Docker
Browse files- .env.example +6 -0
- .gitignore +17 -0
- Dockerfile +1 -0
- README.md +345 -3
- app.py +527 -4
- examples/bash_loops.sh +44 -0
- examples/bash_system_info.sh +29 -0
- examples/hello_world.js +2 -0
- examples/hello_world.py +2 -0
- examples/hello_world.sh +3 -0
- examples/javascript_arrays.js +25 -0
- examples/javascript_objects.js +50 -0
- examples/python_classes.py +42 -0
- examples/python_data.py +41 -0
- examples/python_stdin.py +14 -0
- requirements-dev.txt +3 -0
- requirements.txt +6 -1
- sandbox/__init__.py +1 -0
- sandbox/container_builder.py +84 -0
- sandbox/executor.py +220 -0
- sandbox/file_manager.py +277 -0
- sandbox/images/bash.Dockerfile +27 -0
- sandbox/images/devenv.Dockerfile +108 -0
- sandbox/images/environment_check.sh +95 -0
- sandbox/images/javascript.Dockerfile +26 -0
- sandbox/images/python.Dockerfile +21 -0
- sandbox/language_runners.py +54 -0
- sandbox/models.py +115 -0
- sandbox/session_manager.py +246 -0
- tests/__init__.py +1 -0
- tests/test_api.py +166 -0
- tests/test_executor.py +183 -0
.env.example
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environment Configuration
|
| 2 |
+
MAX_EXECUTION_TIME=30
|
| 3 |
+
MAX_MEMORY_MB=512
|
| 4 |
+
ENABLE_NETWORK=false
|
| 5 |
+
LOG_LEVEL=INFO
|
| 6 |
+
MAX_OUTPUT_SIZE=1048576
|
.gitignore
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
*.pyo
|
| 5 |
+
*.pyd
|
| 6 |
+
.Python
|
| 7 |
+
*.so
|
| 8 |
+
*.egg
|
| 9 |
+
*.egg-info/
|
| 10 |
+
dist/
|
| 11 |
+
build/
|
| 12 |
+
.pytest_cache/
|
| 13 |
+
.coverage
|
| 14 |
+
htmlcov/
|
| 15 |
+
.vscode/
|
| 16 |
+
.idea/
|
| 17 |
+
*.log
|
Dockerfile
CHANGED
|
@@ -13,4 +13,5 @@ COPY --chown=user ./requirements.txt requirements.txt
|
|
| 13 |
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
| 14 |
|
| 15 |
COPY --chown=user . /app
|
|
|
|
| 16 |
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
|
|
| 13 |
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
| 14 |
|
| 15 |
COPY --chown=user . /app
|
| 16 |
+
|
| 17 |
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,352 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
emoji: 🏃
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Code Execution Sandbox
|
| 3 |
emoji: 🏃
|
| 4 |
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# Code Execution Sandbox API 🚀
|
| 11 |
+
|
| 12 |
+
A secure, isolated code execution sandbox API built with FastAPI and Docker. Execute Python, JavaScript, and Bash code in ephemeral containers with strict resource limits and security controls.
|
| 13 |
+
|
| 14 |
+
## ✨ Features
|
| 15 |
+
|
| 16 |
+
- **Multi-Language Support**: Python, JavaScript (Node.js), and Bash
|
| 17 |
+
- **Security First**:
|
| 18 |
+
- Network isolation (no internet access)
|
| 19 |
+
- Resource limits (CPU, memory, timeout)
|
| 20 |
+
- Read-only filesystem
|
| 21 |
+
- Non-root user execution
|
| 22 |
+
- **Automatic Cleanup**: Containers are destroyed after each execution
|
| 23 |
+
- **RESTful API**: Simple JSON-based interface
|
| 24 |
+
- **Resource Management**: Configurable CPU, memory, and execution timeouts
|
| 25 |
+
|
| 26 |
+
## 🚀 Quick Start
|
| 27 |
+
|
| 28 |
+
### Prerequisites
|
| 29 |
+
|
| 30 |
+
- **Docker** installed and running
|
| 31 |
+
- **Python 3.9+**
|
| 32 |
+
|
| 33 |
+
### Installation
|
| 34 |
+
|
| 35 |
+
1. **Clone the repository**:
|
| 36 |
+
```bash
|
| 37 |
+
git clone https://huggingface.co/spaces/fariasultanacodes/isolated-sandbox
|
| 38 |
+
cd isolated-sandbox
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
2. **Install dependencies**:
|
| 42 |
+
```bash
|
| 43 |
+
pip install -r requirements.txt
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
3. **Run the API**:
|
| 47 |
+
```bash
|
| 48 |
+
uvicorn app:app --host 0.0.0.0 --port 7860
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
The API will be available at `http://localhost:7860`
|
| 52 |
+
|
| 53 |
+
## 📡 API Endpoints
|
| 54 |
+
|
| 55 |
+
### `POST /execute`
|
| 56 |
+
|
| 57 |
+
Execute code in an isolated sandbox.
|
| 58 |
+
|
| 59 |
+
**Request Body**:
|
| 60 |
+
```json
|
| 61 |
+
{
|
| 62 |
+
"code": "print('Hello, World!')",
|
| 63 |
+
"language": "python",
|
| 64 |
+
"stdin": "",
|
| 65 |
+
"timeout": 10,
|
| 66 |
+
"memory_limit": 256
|
| 67 |
+
}
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
**Response**:
|
| 71 |
+
```json
|
| 72 |
+
{
|
| 73 |
+
"stdout": "Hello, World!\n",
|
| 74 |
+
"stderr": "",
|
| 75 |
+
"exit_code": 0,
|
| 76 |
+
"execution_time": 0.123,
|
| 77 |
+
"error": null
|
| 78 |
+
}
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
**Parameters**:
|
| 82 |
+
- `code` (string, required): Code to execute
|
| 83 |
+
- `language` (enum, required): `"python"`, `"javascript"`, or `"bash"`
|
| 84 |
+
- `stdin` (string, optional): Standard input for the code
|
| 85 |
+
- `timeout` (integer, optional): Execution timeout in seconds (1-30, default: 10)
|
| 86 |
+
- `memory_limit` (integer, optional): Memory limit in MB (64-512, default: 256)
|
| 87 |
+
|
| 88 |
+
### `GET /languages`
|
| 89 |
+
|
| 90 |
+
List all supported programming languages.
|
| 91 |
+
|
| 92 |
+
**Response**:
|
| 93 |
+
```json
|
| 94 |
+
{
|
| 95 |
+
"languages": [
|
| 96 |
+
{
|
| 97 |
+
"name": "Python",
|
| 98 |
+
"version": "3.11",
|
| 99 |
+
"image": "python:3.11-slim",
|
| 100 |
+
"extensions": [".py"]
|
| 101 |
+
},
|
| 102 |
+
...
|
| 103 |
+
]
|
| 104 |
+
}
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
### `GET /health`
|
| 108 |
+
|
| 109 |
+
Health check endpoint.
|
| 110 |
+
|
| 111 |
+
**Response**:
|
| 112 |
+
```json
|
| 113 |
+
{
|
| 114 |
+
"status": "healthy",
|
| 115 |
+
"docker": "connected"
|
| 116 |
+
}
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
## 💡 Usage Examples
|
| 120 |
+
|
| 121 |
+
### Python Execution
|
| 122 |
+
|
| 123 |
+
```bash
|
| 124 |
+
curl -X POST http://localhost:7860/execute \
|
| 125 |
+
-H "Content-Type: application/json" \
|
| 126 |
+
-d '{
|
| 127 |
+
"code": "for i in range(5):\n print(f\"Count: {i}\")",
|
| 128 |
+
"language": "python"
|
| 129 |
+
}'
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
### JavaScript Execution
|
| 133 |
+
|
| 134 |
+
```bash
|
| 135 |
+
curl -X POST http://localhost:7860/execute \
|
| 136 |
+
-H "Content-Type: application/json" \
|
| 137 |
+
-d '{
|
| 138 |
+
"code": "const arr = [1, 2, 3, 4, 5];\nconsole.log(arr.reduce((a, b) => a + b));",
|
| 139 |
+
"language": "javascript"
|
| 140 |
+
}'
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
### Bash Execution
|
| 144 |
+
|
| 145 |
+
```bash
|
| 146 |
+
curl -X POST http://localhost:7860/execute \
|
| 147 |
+
-H "Content-Type: application/json" \
|
| 148 |
+
-d '{
|
| 149 |
+
"code": "echo \"System Info:\"; uname -a",
|
| 150 |
+
"language": "bash"
|
| 151 |
+
}'
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
### With Timeout
|
| 155 |
+
|
| 156 |
+
```bash
|
| 157 |
+
curl -X POST http://localhost:7860/execute \
|
| 158 |
+
-H "Content-Type: application/json" \
|
| 159 |
+
-d '{
|
| 160 |
+
"code": "import time\ntime.sleep(5)\nprint(\"Done!\")",
|
| 161 |
+
"language": "python",
|
| 162 |
+
"timeout": 2
|
| 163 |
+
}'
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
## 🔒 Security Features
|
| 167 |
+
|
| 168 |
+
1. **Network Isolation**: Containers run with `network_mode="none"`, preventing internet access
|
| 169 |
+
2. **Resource Limits**:
|
| 170 |
+
- CPU: Limited to 0.5 cores
|
| 171 |
+
- Memory: Configurable (64-512 MB)
|
| 172 |
+
- Timeout: Configurable (1-30 seconds)
|
| 173 |
+
- PIDs: Limited to 50 processes
|
| 174 |
+
3. **Filesystem**: Read-only root filesystem (except `/tmp`)
|
| 175 |
+
4. **User Privileges**: Code runs as non-root user (`nobody`)
|
| 176 |
+
5. **Output Limits**: Stdout/stderr truncated at 1MB to prevent memory attacks
|
| 177 |
+
6. **Automatic Cleanup**: Containers are removed immediately after execution
|
| 178 |
+
|
| 179 |
+
## ⚙️ Configuration
|
| 180 |
+
|
| 181 |
+
Create a `.env` file based on `.env.example`:
|
| 182 |
+
|
| 183 |
+
```bash
|
| 184 |
+
MAX_EXECUTION_TIME=30
|
| 185 |
+
MAX_MEMORY_MB=512
|
| 186 |
+
ENABLE_NETWORK=false
|
| 187 |
+
LOG_LEVEL=INFO
|
| 188 |
+
MAX_OUTPUT_SIZE=1048576
|
| 189 |
+
```
|
| 190 |
+
|
| 191 |
+
# Custom Docker Images
|
| 192 |
+
|
| 193 |
+
The sandbox uses pre-built, hardened Docker images for each language:
|
| 194 |
+
|
| 195 |
+
### Python Image
|
| 196 |
+
```dockerfile
|
| 197 |
+
FROM python:3.11-slim-alpine
|
| 198 |
+
# Custom Python sandbox image with security hardening
|
| 199 |
+
```
|
| 200 |
+
Location: `sandbox/images/python.Dockerfile`
|
| 201 |
+
|
| 202 |
+
### JavaScript Image
|
| 203 |
+
```dockerfile
|
| 204 |
+
FROM node:20-alpine
|
| 205 |
+
# Custom Node.js sandbox image with security hardening
|
| 206 |
+
```
|
| 207 |
+
Location: `sandbox/images/javascript.Dockerfile`
|
| 208 |
+
|
| 209 |
+
### Bash Image
|
| 210 |
+
```dockerfile
|
| 211 |
+
FROM bash:5.2-alpine
|
| 212 |
+
# Custom Bash sandbox image with security hardening
|
| 213 |
+
```
|
| 214 |
+
Location: `sandbox/images/bash.Dockerfile`
|
| 215 |
+
|
| 216 |
+
**Benefits**:
|
| 217 |
+
- Smaller image sizes (Alpine Linux base)
|
| 218 |
+
- Security hardened with non-root users
|
| 219 |
+
- Minimal attack surface
|
| 220 |
+
- Faster container startup times
|
| 221 |
+
|
| 222 |
+
You can build these images locally if needed:
|
| 223 |
+
```bash
|
| 224 |
+
# Build Python sandbox image
|
| 225 |
+
docker build -t sandbox-python:latest -f sandbox/images/python.Dockerfile sandbox/images/
|
| 226 |
+
|
| 227 |
+
# Build JavaScript sandbox image
|
| 228 |
+
docker build -t sandbox-javascript:latest -f sandbox/images/javascript.Dockerfile sandbox/images/
|
| 229 |
+
|
| 230 |
+
# Build Bash sandbox image
|
| 231 |
+
docker build -t sandbox-bash:latest -f sandbox/images/bash.Dockerfile sandbox/images/
|
| 232 |
+
```
|
| 233 |
+
|
| 234 |
+
## 🐳 Docker Deployment
|
| 235 |
+
|
| 236 |
+
### Option 1: Self-Hosted
|
| 237 |
+
|
| 238 |
+
The application requires access to the Docker daemon. Run with Docker socket mounted:
|
| 239 |
+
|
| 240 |
+
```bash
|
| 241 |
+
docker build -t code-sandbox .
|
| 242 |
+
docker run -d \
|
| 243 |
+
-p 7860:7860 \
|
| 244 |
+
-v /var/run/docker.sock:/var/run/docker.sock \
|
| 245 |
+
--name code-sandbox \
|
| 246 |
+
code-sandbox
|
| 247 |
+
```
|
| 248 |
+
|
| 249 |
+
### Option 2: Cloud Platforms
|
| 250 |
+
|
| 251 |
+
Deploy to platforms with Docker support:
|
| 252 |
+
- **Modal**: Use Modal's container runtime
|
| 253 |
+
- **Fly.io**: Deploy with Docker support
|
| 254 |
+
- **Railway**: Deploy with Docker socket access
|
| 255 |
+
- **Render**: Deploy with Docker enabled
|
| 256 |
+
|
| 257 |
+
> **Note**: Hugging Face Spaces does not support Docker-in-Docker, so this requires self-hosting or alternative platforms.
|
| 258 |
+
|
| 259 |
+
## 📝 Examples
|
| 260 |
+
|
| 261 |
+
Check the `examples/` directory for sample code in multiple languages:
|
| 262 |
+
|
| 263 |
+
### Python Examples
|
| 264 |
+
- `examples/hello_world.py` - Simple hello world
|
| 265 |
+
- `examples/python_stdin.py` - Input/output and calculations
|
| 266 |
+
- `examples/python_classes.py` - Object-oriented programming
|
| 267 |
+
- `examples/python_data.py` - Data processing and algorithms
|
| 268 |
+
|
| 269 |
+
### JavaScript Examples
|
| 270 |
+
- `examples/hello_world.js` - Simple hello world
|
| 271 |
+
- `examples/javascript_arrays.js` - Array operations and functions
|
| 272 |
+
- `examples/javascript_objects.js` - Objects and classes
|
| 273 |
+
|
| 274 |
+
### Bash Examples
|
| 275 |
+
- `examples/hello_world.sh` - Simple hello world
|
| 276 |
+
- `examples/bash_system_info.sh` - System information display
|
| 277 |
+
- `examples/bash_loops.sh` - Loop operations and functions
|
| 278 |
+
|
| 279 |
+
## 🛠️ Development
|
| 280 |
+
|
| 281 |
+
### Run Tests
|
| 282 |
+
|
| 283 |
+
```bash
|
| 284 |
+
# Install dev dependencies
|
| 285 |
+
pip install pytest pytest-asyncio httpx
|
| 286 |
+
|
| 287 |
+
# Run tests
|
| 288 |
+
pytest tests/ -v
|
| 289 |
+
```
|
| 290 |
+
|
| 291 |
+
### Local Development
|
| 292 |
+
|
| 293 |
+
```bash
|
| 294 |
+
# Run with hot reload
|
| 295 |
+
uvicorn app:app --reload --host 0.0.0.0 --port 7860
|
| 296 |
+
```
|
| 297 |
+
|
| 298 |
+
## 📊 Architecture
|
| 299 |
+
|
| 300 |
+
```
|
| 301 |
+
┌─────────────┐
|
| 302 |
+
│ Client │
|
| 303 |
+
└──────┬──────┘
|
| 304 |
+
│ HTTP POST /execute
|
| 305 |
+
▼
|
| 306 |
+
┌─────────────────┐
|
| 307 |
+
│ FastAPI App │
|
| 308 |
+
│ (app.py) │
|
| 309 |
+
└──────┬──────────┘
|
| 310 |
+
│
|
| 311 |
+
▼
|
| 312 |
+
┌─────────────────┐
|
| 313 |
+
│ SandboxExecutor │
|
| 314 |
+
│ (executor.py) │
|
| 315 |
+
└──────┬──────────┘
|
| 316 |
+
│ Docker SDK
|
| 317 |
+
▼
|
| 318 |
+
┌─────────────────┐
|
| 319 |
+
│ Ephemeral │
|
| 320 |
+
│ Container │
|
| 321 |
+
│ (Python/JS/Bash)│
|
| 322 |
+
└─────────────────┘
|
| 323 |
+
│
|
| 324 |
+
▼ (cleanup)
|
| 325 |
+
♻️ Removed
|
| 326 |
+
```
|
| 327 |
+
|
| 328 |
+
## ⚠️ Important Notes
|
| 329 |
+
|
| 330 |
+
1. **Rate Limiting**: For production use, implement rate limiting to prevent abuse
|
| 331 |
+
2. **Authentication**: Add authentication for public deployments
|
| 332 |
+
3. **Monitoring**: Monitor Docker resource usage and container counts
|
| 333 |
+
4. **Resource Costs**: Each execution creates a new container; consider costs at scale
|
| 334 |
+
5. **Docker Requirement**: The host system must have Docker installed and accessible
|
| 335 |
+
|
| 336 |
+
## 📄 License
|
| 337 |
+
|
| 338 |
+
This project is open-source and available under the MIT License.
|
| 339 |
+
|
| 340 |
+
## 🤝 Contributing
|
| 341 |
+
|
| 342 |
+
Contributions welcome! Please feel free to submit issues or pull requests.
|
| 343 |
+
|
| 344 |
+
## 🔗 Links
|
| 345 |
+
|
| 346 |
+
- [Hugging Face Space](https://huggingface.co/spaces/fariasultanacodes/isolated-sandbox)
|
| 347 |
+
- [Docker Documentation](https://docs.docker.com/)
|
| 348 |
+
- [FastAPI Documentation](https://fastapi.tiangolo.com/)
|
| 349 |
+
|
| 350 |
+
---
|
| 351 |
+
|
| 352 |
+
**Built with ❤️ using FastAPI and Docker**
|
app.py
CHANGED
|
@@ -1,7 +1,530 @@
|
|
| 1 |
-
from fastapi import FastAPI
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
app = FastAPI()
|
| 4 |
|
| 5 |
@app.get("/")
|
| 6 |
-
def
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, HTTPException, status, UploadFile, File, Path as FastAPIPath
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from fastapi.responses import JSONResponse, Response
|
| 4 |
+
import logging
|
| 5 |
+
from contextlib import asynccontextmanager
|
| 6 |
+
from typing import List
|
| 7 |
+
|
| 8 |
+
from sandbox.executor import SandboxExecutor
|
| 9 |
+
from sandbox.session_manager import SessionManager
|
| 10 |
+
from sandbox.file_manager import FileManager
|
| 11 |
+
from sandbox.container_builder import ContainerBuilder
|
| 12 |
+
from sandbox.models import (
|
| 13 |
+
ExecutionRequest, ExecutionResponse, SandboxConfig, Language,
|
| 14 |
+
CreateSessionRequest, SessionResponse, FileInfo, FileUploadResponse,
|
| 15 |
+
ExecuteInSessionRequest, ExecuteFileRequest
|
| 16 |
+
)
|
| 17 |
+
from sandbox.language_runners import LanguageRunner
|
| 18 |
+
|
| 19 |
+
# Configure logging
|
| 20 |
+
logging.basicConfig(
|
| 21 |
+
level=logging.INFO,
|
| 22 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 23 |
+
)
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
# Global instances
|
| 27 |
+
executor: SandboxExecutor = None
|
| 28 |
+
session_manager: SessionManager = None
|
| 29 |
+
file_manager: FileManager = None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@asynccontextmanager
|
| 33 |
+
async def lifespan(app: FastAPI):
|
| 34 |
+
"""Initialize and cleanup resources"""
|
| 35 |
+
global executor, session_manager, file_manager
|
| 36 |
+
try:
|
| 37 |
+
# Build/verify devenv image
|
| 38 |
+
logger.info("Checking development environment image...")
|
| 39 |
+
builder = ContainerBuilder()
|
| 40 |
+
if not builder.ensure_devenv_image():
|
| 41 |
+
logger.warning("Dev environment image not available, sessions will fail")
|
| 42 |
+
|
| 43 |
+
# Initialize services
|
| 44 |
+
logger.info("Initializing sandbox services...")
|
| 45 |
+
executor = SandboxExecutor(SandboxConfig())
|
| 46 |
+
session_manager = SessionManager(SandboxConfig())
|
| 47 |
+
file_manager = FileManager()
|
| 48 |
+
logger.info("All services initialized successfully")
|
| 49 |
+
yield
|
| 50 |
+
except Exception as e:
|
| 51 |
+
logger.error(f"Failed to initialize services: {e}")
|
| 52 |
+
raise
|
| 53 |
+
finally:
|
| 54 |
+
# Cleanup on shutdown
|
| 55 |
+
if session_manager:
|
| 56 |
+
logger.info("Shutting down session manager...")
|
| 57 |
+
session_manager.shutdown()
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
app = FastAPI(
|
| 61 |
+
title="Code Execution Sandbox API",
|
| 62 |
+
description="Execute code in isolated containers with persistent VM-like sessions and file system operations",
|
| 63 |
+
version="2.0.0",
|
| 64 |
+
lifespan=lifespan
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
# CORS middleware
|
| 68 |
+
app.add_middleware(
|
| 69 |
+
CORSMiddleware,
|
| 70 |
+
allow_origins=["*"],
|
| 71 |
+
allow_credentials=True,
|
| 72 |
+
allow_methods=["*"],
|
| 73 |
+
allow_headers=["*"],
|
| 74 |
+
)
|
| 75 |
|
|
|
|
| 76 |
|
| 77 |
@app.get("/")
|
| 78 |
+
def root():
|
| 79 |
+
"""Root endpoint with API information"""
|
| 80 |
+
return {
|
| 81 |
+
"name": "Code Execution Sandbox API",
|
| 82 |
+
"version": "2.0.0",
|
| 83 |
+
"status": "running",
|
| 84 |
+
"features": {
|
| 85 |
+
"stateless_execution": "/execute",
|
| 86 |
+
"persistent_sessions": "/sessions",
|
| 87 |
+
"file_operations": True,
|
| 88 |
+
"multi_language": True
|
| 89 |
+
},
|
| 90 |
+
"supported_languages": ["python", "javascript", "bash"],
|
| 91 |
+
"endpoints": {
|
| 92 |
+
"execute": "/execute (stateless)",
|
| 93 |
+
"sessions": "/sessions (create/list)",
|
| 94 |
+
"session_detail": "/sessions/{session_id}",
|
| 95 |
+
"files": "/sessions/{session_id}/files",
|
| 96 |
+
"execute_in_session": "/sessions/{session_id}/execute",
|
| 97 |
+
"languages": "/languages",
|
| 98 |
+
"health": "/health"
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@app.get("/health")
|
| 104 |
+
def health_check():
|
| 105 |
+
"""Health check endpoint"""
|
| 106 |
+
try:
|
| 107 |
+
if executor and executor.client:
|
| 108 |
+
executor.client.ping()
|
| 109 |
+
|
| 110 |
+
# Check session manager
|
| 111 |
+
session_count = len(session_manager.sessions) if session_manager else 0
|
| 112 |
+
|
| 113 |
+
return {
|
| 114 |
+
"status": "healthy",
|
| 115 |
+
"docker": "connected",
|
| 116 |
+
"active_sessions": session_count
|
| 117 |
+
}
|
| 118 |
+
except Exception as e:
|
| 119 |
+
logger.error(f"Health check failed: {e}")
|
| 120 |
+
raise HTTPException(
|
| 121 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 122 |
+
detail=f"Service unhealthy: {str(e)}"
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
@app.get("/languages")
|
| 127 |
+
def list_languages():
|
| 128 |
+
"""List all supported programming languages"""
|
| 129 |
+
return {
|
| 130 |
+
"languages": LanguageRunner.get_all_languages()
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# ========== Stateless Execution (backward compatible) ==========
|
| 135 |
+
|
| 136 |
+
@app.post("/execute", response_model=ExecutionResponse)
|
| 137 |
+
def execute_code(request: ExecutionRequest):
|
| 138 |
+
"""
|
| 139 |
+
Execute code in an isolated ephemeral container (stateless).
|
| 140 |
+
|
| 141 |
+
This is the original execution method - creates a fresh container,
|
| 142 |
+
executes code, and destroys the container immediately.
|
| 143 |
+
"""
|
| 144 |
+
if not executor:
|
| 145 |
+
raise HTTPException(
|
| 146 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 147 |
+
detail="Sandbox executor not initialized"
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
try:
|
| 151 |
+
logger.info(f"Stateless execution: {request.language} code")
|
| 152 |
+
result = executor.execute(request)
|
| 153 |
+
return result
|
| 154 |
+
except Exception as e:
|
| 155 |
+
logger.error(f"Execution failed: {e}", exc_info=True)
|
| 156 |
+
raise HTTPException(
|
| 157 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 158 |
+
detail=f"Execution failed: {str(e)}"
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ========== Session Management ==========
|
| 163 |
+
|
| 164 |
+
@app.post("/sessions", response_model=SessionResponse, status_code=status.HTTP_201_CREATED)
|
| 165 |
+
def create_session(request: CreateSessionRequest):
|
| 166 |
+
"""
|
| 167 |
+
Create a new persistent VM-like session.
|
| 168 |
+
|
| 169 |
+
The session is a long-running container with persistent storage,
|
| 170 |
+
supporting file uploads and multiple code executions.
|
| 171 |
+
"""
|
| 172 |
+
if not session_manager:
|
| 173 |
+
raise HTTPException(
|
| 174 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 175 |
+
detail="Session manager not initialized"
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
try:
|
| 179 |
+
logger.info(f"Creating new session with metadata: {request.metadata}")
|
| 180 |
+
session = session_manager.create_session(request)
|
| 181 |
+
return session
|
| 182 |
+
except RuntimeError as e:
|
| 183 |
+
raise HTTPException(
|
| 184 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 185 |
+
detail=str(e)
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
@app.get("/sessions", response_model=List[SessionResponse])
|
| 190 |
+
def list_sessions():
|
| 191 |
+
"""List all active sessions"""
|
| 192 |
+
if not session_manager:
|
| 193 |
+
raise HTTPException(
|
| 194 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 195 |
+
detail="Session manager not initialized"
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
sessions = session_manager.list_sessions()
|
| 199 |
+
return sessions
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
@app.get("/sessions/{session_id}", response_model=SessionResponse)
|
| 203 |
+
def get_session(session_id: str = FastAPIPath(..., description="Session ID")):
|
| 204 |
+
"""Get session details by ID"""
|
| 205 |
+
if not session_manager:
|
| 206 |
+
raise HTTPException(
|
| 207 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 208 |
+
detail="Session manager not initialized"
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
session = session_manager.get_session(session_id)
|
| 212 |
+
if not session:
|
| 213 |
+
raise HTTPException(
|
| 214 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 215 |
+
detail=f"Session {session_id} not found"
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
# Update file count
|
| 219 |
+
if file_manager:
|
| 220 |
+
try:
|
| 221 |
+
files = file_manager.list_files(session.container_id)
|
| 222 |
+
session.files_count = len(files)
|
| 223 |
+
except:
|
| 224 |
+
pass
|
| 225 |
+
|
| 226 |
+
return session
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
@app.delete("/sessions/{session_id}")
|
| 230 |
+
def destroy_session(session_id: str = FastAPIPath(..., description="Session ID")):
|
| 231 |
+
"""Destroy a session and cleanup all resources"""
|
| 232 |
+
if not session_manager:
|
| 233 |
+
raise HTTPException(
|
| 234 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 235 |
+
detail="Session manager not initialized"
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
success = session_manager.destroy_session(session_id)
|
| 239 |
+
if not success:
|
| 240 |
+
raise HTTPException(
|
| 241 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 242 |
+
detail=f"Session {session_id} not found"
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
return {"message": f"Session {session_id} destroyed successfully"}
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
# ========== File Operations ==========
|
| 249 |
+
|
| 250 |
+
@app.post("/sessions/{session_id}/files", response_model=FileUploadResponse)
|
| 251 |
+
async def upload_file(
|
| 252 |
+
session_id: str = FastAPIPath(..., description="Session ID"),
|
| 253 |
+
file: UploadFile = File(..., description="File to upload")
|
| 254 |
+
):
|
| 255 |
+
"""Upload a file to session workspace"""
|
| 256 |
+
if not session_manager or not file_manager:
|
| 257 |
+
raise HTTPException(
|
| 258 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 259 |
+
detail="Services not initialized"
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
# Get session
|
| 263 |
+
session = session_manager.get_session(session_id)
|
| 264 |
+
if not session:
|
| 265 |
+
raise HTTPException(
|
| 266 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 267 |
+
detail=f"Session {session_id} not found"
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
try:
|
| 271 |
+
# Read file data
|
| 272 |
+
file_data = await file.read()
|
| 273 |
+
|
| 274 |
+
# Upload to container
|
| 275 |
+
result = file_manager.upload_file(
|
| 276 |
+
container_id=session.container_id,
|
| 277 |
+
filename=file.filename,
|
| 278 |
+
file_data=file_data
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
# Update session activity
|
| 282 |
+
session_manager.update_activity(session_id)
|
| 283 |
+
|
| 284 |
+
return result
|
| 285 |
+
|
| 286 |
+
except ValueError as e:
|
| 287 |
+
raise HTTPException(
|
| 288 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 289 |
+
detail=str(e)
|
| 290 |
+
)
|
| 291 |
+
except Exception as e:
|
| 292 |
+
logger.error(f"File upload failed: {e}", exc_info=True)
|
| 293 |
+
raise HTTPException(
|
| 294 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 295 |
+
detail=f"File upload failed: {str(e)}"
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
@app.get("/sessions/{session_id}/files", response_model=List[FileInfo])
|
| 300 |
+
def list_files(session_id: str = FastAPIPath(..., description="Session ID")):
|
| 301 |
+
"""List files in session workspace"""
|
| 302 |
+
if not session_manager or not file_manager:
|
| 303 |
+
raise HTTPException(
|
| 304 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 305 |
+
detail="Services not initialized"
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
# Get session
|
| 309 |
+
session = session_manager.get_session(session_id)
|
| 310 |
+
if not session:
|
| 311 |
+
raise HTTPException(
|
| 312 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 313 |
+
detail=f"Session {session_id} not found"
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
try:
|
| 317 |
+
files = file_manager.list_files(session.container_id)
|
| 318 |
+
return files
|
| 319 |
+
except Exception as e:
|
| 320 |
+
logger.error(f"File listing failed: {e}", exc_info=True)
|
| 321 |
+
raise HTTPException(
|
| 322 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 323 |
+
detail=f"File listing failed: {str(e)}"
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
@app.get("/sessions/{session_id}/files/{filepath:path}")
|
| 328 |
+
def download_file(
|
| 329 |
+
session_id: str = FastAPIPath(..., description="Session ID"),
|
| 330 |
+
filepath: str = FastAPIPath(..., description="File path relative to workspace")
|
| 331 |
+
):
|
| 332 |
+
"""Download a file from session workspace"""
|
| 333 |
+
if not session_manager or not file_manager:
|
| 334 |
+
raise HTTPException(
|
| 335 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 336 |
+
detail="Services not initialized"
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
# Get session
|
| 340 |
+
session = session_manager.get_session(session_id)
|
| 341 |
+
if not session:
|
| 342 |
+
raise HTTPException(
|
| 343 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 344 |
+
detail=f"Session {session_id} not found"
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
try:
|
| 348 |
+
file_data = file_manager.download_file(session.container_id, filepath)
|
| 349 |
+
|
| 350 |
+
# Determine content type
|
| 351 |
+
import mimetypes
|
| 352 |
+
content_type, _ = mimetypes.guess_type(filepath)
|
| 353 |
+
|
| 354 |
+
return Response(
|
| 355 |
+
content=file_data,
|
| 356 |
+
media_type=content_type or "application/octet-stream",
|
| 357 |
+
headers={
|
| 358 |
+
"Content-Disposition": f"attachment; filename={filepath.split('/')[-1]}"
|
| 359 |
+
}
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
except ValueError as e:
|
| 363 |
+
raise HTTPException(
|
| 364 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 365 |
+
detail=str(e)
|
| 366 |
+
)
|
| 367 |
+
except Exception as e:
|
| 368 |
+
logger.error(f"File download failed: {e}", exc_info=True)
|
| 369 |
+
raise HTTPException(
|
| 370 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 371 |
+
detail=f"File download failed: {str(e)}"
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
# ========== Execute in Session ==========
|
| 376 |
+
|
| 377 |
+
@app.post("/sessions/{session_id}/execute", response_model=ExecutionResponse)
|
| 378 |
+
def execute_in_session(
|
| 379 |
+
session_id: str = FastAPIPath(..., description="Session ID"),
|
| 380 |
+
request: ExecuteInSessionRequest = None
|
| 381 |
+
):
|
| 382 |
+
"""Execute code in an existing session (persistent state)"""
|
| 383 |
+
if not session_manager:
|
| 384 |
+
raise HTTPException(
|
| 385 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 386 |
+
detail="Session manager not initialized"
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
# Get session
|
| 390 |
+
session = session_manager.get_session(session_id)
|
| 391 |
+
if not session:
|
| 392 |
+
raise HTTPException(
|
| 393 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 394 |
+
detail=f"Session {session_id} not found"
|
| 395 |
+
)
|
| 396 |
+
|
| 397 |
+
try:
|
| 398 |
+
import time
|
| 399 |
+
from docker.errors import DockerException
|
| 400 |
+
|
| 401 |
+
container = executor.client.containers.get(session.container_id)
|
| 402 |
+
runner_config = LanguageRunner.get_runner_config(request.language)
|
| 403 |
+
|
| 404 |
+
start_time = time.time()
|
| 405 |
+
|
| 406 |
+
# Execute command in running container
|
| 407 |
+
exec_result = container.exec_run(
|
| 408 |
+
cmd=runner_config["command"] + [request.code],
|
| 409 |
+
workdir=request.working_dir,
|
| 410 |
+
demux=True,
|
| 411 |
+
stream=False
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
execution_time = time.time() - start_time
|
| 415 |
+
|
| 416 |
+
# Parse output
|
| 417 |
+
stdout = exec_result.output[0].decode('utf-8', errors='replace') if exec_result.output[0] else ""
|
| 418 |
+
stderr = exec_result.output[1].decode('utf-8', errors='replace') if exec_result.output[1] else ""
|
| 419 |
+
|
| 420 |
+
# Update session activity
|
| 421 |
+
session_manager.update_activity(session_id)
|
| 422 |
+
|
| 423 |
+
return ExecutionResponse(
|
| 424 |
+
stdout=stdout,
|
| 425 |
+
stderr=stderr,
|
| 426 |
+
exit_code=exec_result.exit_code,
|
| 427 |
+
execution_time=round(execution_time, 3),
|
| 428 |
+
error=None if exec_result.exit_code == 0 else "Execution failed"
|
| 429 |
+
)
|
| 430 |
+
|
| 431 |
+
except DockerException as e:
|
| 432 |
+
logger.error(f"Docker error: {e}", exc_info=True)
|
| 433 |
+
raise HTTPException(
|
| 434 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 435 |
+
detail=f"Execution failed: {str(e)}"
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
@app.post("/sessions/{session_id}/execute-file", response_model=ExecutionResponse)
|
| 440 |
+
def execute_file_in_session(
|
| 441 |
+
session_id: str = FastAPIPath(..., description="Session ID"),
|
| 442 |
+
request: ExecuteFileRequest = None
|
| 443 |
+
):
|
| 444 |
+
"""Execute an uploaded file in session"""
|
| 445 |
+
if not session_manager:
|
| 446 |
+
raise HTTPException(
|
| 447 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 448 |
+
detail="Session manager not initialized"
|
| 449 |
+
)
|
| 450 |
+
|
| 451 |
+
# Get session
|
| 452 |
+
session = session_manager.get_session(session_id)
|
| 453 |
+
if not session:
|
| 454 |
+
raise HTTPException(
|
| 455 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 456 |
+
detail=f"Session {session_id} not found"
|
| 457 |
+
)
|
| 458 |
+
|
| 459 |
+
try:
|
| 460 |
+
import time
|
| 461 |
+
container = executor.client.containers.get(session.container_id)
|
| 462 |
+
runner_config = LanguageRunner.get_runner_config(request.language)
|
| 463 |
+
|
| 464 |
+
# Build command based on language
|
| 465 |
+
if request.language == Language.PYTHON:
|
| 466 |
+
cmd = ["python", request.filepath] + request.args
|
| 467 |
+
elif request.language == Language.JAVASCRIPT:
|
| 468 |
+
cmd = ["node", request.filepath] + request.args
|
| 469 |
+
elif request.language == Language.BASH:
|
| 470 |
+
cmd = ["bash", request.filepath] + request.args
|
| 471 |
+
else:
|
| 472 |
+
cmd = runner_config["command"] + [request.filepath] + request.args
|
| 473 |
+
|
| 474 |
+
start_time = time.time()
|
| 475 |
+
|
| 476 |
+
# Execute file
|
| 477 |
+
exec_result = container.exec_run(
|
| 478 |
+
cmd=cmd,
|
| 479 |
+
workdir="/workspace",
|
| 480 |
+
demux=True,
|
| 481 |
+
stream=False
|
| 482 |
+
)
|
| 483 |
+
|
| 484 |
+
execution_time = time.time() - start_time
|
| 485 |
+
|
| 486 |
+
# Parse output
|
| 487 |
+
stdout = exec_result.output[0].decode('utf-8', errors='replace') if exec_result.output[0] else ""
|
| 488 |
+
stderr = exec_result.output[1].decode('utf-8', errors='replace') if exec_result.output[1] else ""
|
| 489 |
+
|
| 490 |
+
# Update session activity
|
| 491 |
+
session_manager.update_activity(session_id)
|
| 492 |
+
|
| 493 |
+
return ExecutionResponse(
|
| 494 |
+
stdout=stdout,
|
| 495 |
+
stderr=stderr,
|
| 496 |
+
exit_code=exec_result.exit_code,
|
| 497 |
+
execution_time=round(execution_time, 3),
|
| 498 |
+
error=None if exec_result.exit_code == 0 else "Execution failed"
|
| 499 |
+
)
|
| 500 |
+
|
| 501 |
+
except Exception as e:
|
| 502 |
+
logger.error(f"File execution failed: {e}", exc_info=True)
|
| 503 |
+
raise HTTPException(
|
| 504 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 505 |
+
detail=f"File execution failed: {str(e)}"
|
| 506 |
+
)
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
@app.exception_handler(Exception)
|
| 510 |
+
async def global_exception_handler( request, exc):
|
| 511 |
+
"""Global exception handler"""
|
| 512 |
+
logger.error(f"Unhandled exception: {exc}", exc_info=True)
|
| 513 |
+
return JSONResponse(
|
| 514 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 515 |
+
content={
|
| 516 |
+
"error": "Internal server error",
|
| 517 |
+
"detail": str(exc)
|
| 518 |
+
}
|
| 519 |
+
)
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
if __name__ == "__main__":
|
| 523 |
+
import uvicorn
|
| 524 |
+
uvicorn.run(
|
| 525 |
+
"app:app",
|
| 526 |
+
host="0.0.0.0",
|
| 527 |
+
port=7860,
|
| 528 |
+
reload=False,
|
| 529 |
+
log_level="info"
|
| 530 |
+
)
|
examples/bash_loops.sh
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Bash Example: Loop and Array Processing
|
| 3 |
+
echo "=== Bash Scripting Examples ==="
|
| 4 |
+
echo
|
| 5 |
+
|
| 6 |
+
# Array of numbers
|
| 7 |
+
numbers=(1 2 3 4 5 6 7 8 9 10)
|
| 8 |
+
|
| 9 |
+
# Calculate sum using loop
|
| 10 |
+
sum=0
|
| 11 |
+
for num in "${numbers[@]}"; do
|
| 12 |
+
sum=$((sum + num))
|
| 13 |
+
done
|
| 14 |
+
|
| 15 |
+
echo "Array: ${numbers[*]}"
|
| 16 |
+
echo "Sum of numbers: $sum"
|
| 17 |
+
echo "Average: $((sum / ${#numbers[@]}))"
|
| 18 |
+
echo
|
| 19 |
+
|
| 20 |
+
# Find even numbers
|
| 21 |
+
echo "Even numbers:"
|
| 22 |
+
for num in "${numbers[@]}"; do
|
| 23 |
+
if [ $((num % 2)) -eq 0 ]; then
|
| 24 |
+
echo " $num"
|
| 25 |
+
fi
|
| 26 |
+
done
|
| 27 |
+
echo
|
| 28 |
+
|
| 29 |
+
# Create a simple counter
|
| 30 |
+
count=1
|
| 31 |
+
while [ $count -le 5 ]; do
|
| 32 |
+
echo "Count: $count"
|
| 33 |
+
count=$((count + 1))
|
| 34 |
+
done
|
| 35 |
+
echo
|
| 36 |
+
|
| 37 |
+
# Function example
|
| 38 |
+
greet() {
|
| 39 |
+
local name=$1
|
| 40 |
+
echo "Hello, $name! Welcome to bash scripting."
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
greet "Alice"
|
| 44 |
+
greet "Bob"
|
examples/bash_system_info.sh
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Bash Example: System Information
|
| 3 |
+
echo "=== System Information ==="
|
| 4 |
+
echo
|
| 5 |
+
|
| 6 |
+
# Display basic system info
|
| 7 |
+
echo "Hostname: $(hostname)"
|
| 8 |
+
echo "Current user: $(whoami)"
|
| 9 |
+
echo "Current directory: $(pwd)"
|
| 10 |
+
echo "Current time: $(date)"
|
| 11 |
+
echo
|
| 12 |
+
|
| 13 |
+
# Display system resources
|
| 14 |
+
echo "Memory usage:"
|
| 15 |
+
free -h
|
| 16 |
+
echo
|
| 17 |
+
|
| 18 |
+
echo "Disk usage:"
|
| 19 |
+
df -h / | tail -1
|
| 20 |
+
echo
|
| 21 |
+
|
| 22 |
+
# Display OS information
|
| 23 |
+
echo "Operating System:"
|
| 24 |
+
cat /etc/os-release | grep PRETTY_NAME
|
| 25 |
+
echo
|
| 26 |
+
|
| 27 |
+
# Process information
|
| 28 |
+
echo "Number of running processes: $(ps aux | wc -l)"
|
| 29 |
+
echo "Current shell: $SHELL"
|
examples/hello_world.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Example: Hello World in JavaScript
|
| 2 |
+
console.log("Hello, World!");
|
examples/hello_world.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Example: Hello World in Python
|
| 2 |
+
print("Hello, World!")
|
examples/hello_world.sh
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Example: Hello World in Bash
|
| 3 |
+
echo "Hello, World!"
|
examples/javascript_arrays.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// JavaScript Example: Array Processing and Functions
|
| 2 |
+
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
| 3 |
+
|
| 4 |
+
// Calculate sum of all numbers
|
| 5 |
+
const sum = numbers.reduce((acc, num) => acc + num, 0);
|
| 6 |
+
console.log(`Sum of numbers: ${sum}`);
|
| 7 |
+
|
| 8 |
+
// Find even numbers
|
| 9 |
+
const evenNumbers = numbers.filter(num => num % 2 === 0);
|
| 10 |
+
console.log(`Even numbers: ${evenNumbers.join(', ')}`);
|
| 11 |
+
|
| 12 |
+
// Calculate square of each number
|
| 13 |
+
const squares = numbers.map(num => num * num);
|
| 14 |
+
console.log(`Squares: ${squares.join(', ')}`);
|
| 15 |
+
|
| 16 |
+
// Fibonacci sequence
|
| 17 |
+
function fibonacci(n) {
|
| 18 |
+
if (n <= 1) return n;
|
| 19 |
+
return fibonacci(n - 1) + fibonacci(n - 2);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
console.log('\nFirst 10 Fibonacci numbers:');
|
| 23 |
+
for (let i = 0; i < 10; i++) {
|
| 24 |
+
console.log(fibonacci(i));
|
| 25 |
+
}
|
examples/javascript_objects.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// JavaScript Example: Objects and Classes
|
| 2 |
+
class Calculator {
|
| 3 |
+
constructor() {
|
| 4 |
+
this.history = [];
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
add(a, b) {
|
| 8 |
+
const result = a + b;
|
| 9 |
+
this.history.push(`${a} + ${b} = ${result}`);
|
| 10 |
+
return result;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
multiply(a, b) {
|
| 14 |
+
const result = a * b;
|
| 15 |
+
this.history.push(`${a} * ${b} = ${result}`);
|
| 16 |
+
return result;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
showHistory() {
|
| 20 |
+
console.log('Calculation History:');
|
| 21 |
+
this.history.forEach(entry => console.log(entry));
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
// Create calculator instance
|
| 26 |
+
const calc = new Calculator();
|
| 27 |
+
|
| 28 |
+
// Perform calculations
|
| 29 |
+
console.log('10 + 5 =', calc.add(10, 5));
|
| 30 |
+
console.log('10 * 5 =', calc.multiply(10, 5));
|
| 31 |
+
console.log('7 * 8 =', calc.multiply(7, 8));
|
| 32 |
+
|
| 33 |
+
// Show history
|
| 34 |
+
calc.showHistory();
|
| 35 |
+
|
| 36 |
+
// Object with methods
|
| 37 |
+
const person = {
|
| 38 |
+
name: 'Alice',
|
| 39 |
+
age: 30,
|
| 40 |
+
greet() {
|
| 41 |
+
return `Hello, I'm ${this.name} and I'm ${this.age} years old.`;
|
| 42 |
+
},
|
| 43 |
+
haveBirthday() {
|
| 44 |
+
this.age++;
|
| 45 |
+
}
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
console.log(person.greet());
|
| 49 |
+
person.haveBirthday();
|
| 50 |
+
console.log(person.greet());
|
examples/python_classes.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python Example: Classes and Object-Oriented Programming
|
| 2 |
+
class BankAccount:
|
| 3 |
+
def __init__(self, owner, balance=0):
|
| 4 |
+
self.owner = owner
|
| 5 |
+
self.balance = balance
|
| 6 |
+
self.transactions = []
|
| 7 |
+
|
| 8 |
+
def deposit(self, amount):
|
| 9 |
+
if amount > 0:
|
| 10 |
+
self.balance += amount
|
| 11 |
+
self.transactions.append(f"Deposited: ${amount}")
|
| 12 |
+
return True
|
| 13 |
+
return False
|
| 14 |
+
|
| 15 |
+
def withdraw(self, amount):
|
| 16 |
+
if amount <= self.balance and amount > 0:
|
| 17 |
+
self.balance -= amount
|
| 18 |
+
self.transactions.append(f"Withdrew: ${amount}")
|
| 19 |
+
return True
|
| 20 |
+
return False
|
| 21 |
+
|
| 22 |
+
def get_balance(self):
|
| 23 |
+
return f"${self.balance}"
|
| 24 |
+
|
| 25 |
+
def show_transactions(self):
|
| 26 |
+
print(f"Transaction history for {self.owner}:")
|
| 27 |
+
for transaction in self.transactions:
|
| 28 |
+
print(f" - {transaction}")
|
| 29 |
+
|
| 30 |
+
# Create account
|
| 31 |
+
account = BankAccount("John Doe", 1000)
|
| 32 |
+
|
| 33 |
+
print(f"Account created for {account.owner}")
|
| 34 |
+
print(f"Initial balance: {account.get_balance()}")
|
| 35 |
+
|
| 36 |
+
# Perform transactions
|
| 37 |
+
account.deposit(500)
|
| 38 |
+
account.withdraw(200)
|
| 39 |
+
account.deposit(100)
|
| 40 |
+
|
| 41 |
+
print(f"Current balance: {account.get_balance()}")
|
| 42 |
+
account.show_transactions()
|
examples/python_data.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python Example: Data Processing and File Operations
|
| 2 |
+
import json
|
| 3 |
+
|
| 4 |
+
# Sample data
|
| 5 |
+
data = [
|
| 6 |
+
{"name": "Alice", "age": 25, "city": "New York"},
|
| 7 |
+
{"name": "Bob", "age": 30, "city": "San Francisco"},
|
| 8 |
+
{"name": "Charlie", "age": 35, "city": "Boston"},
|
| 9 |
+
{"name": "Diana", "age": 28, "city": "Chicago"},
|
| 10 |
+
{"name": "Eve", "age": 22, "city": "New York"}
|
| 11 |
+
]
|
| 12 |
+
|
| 13 |
+
print("=== Data Processing Demo ===\n")
|
| 14 |
+
|
| 15 |
+
# Filter people over 27
|
| 16 |
+
older_people = [person for person in data if person["age"] > 27]
|
| 17 |
+
print(f"People over 27: {len(older_people)}")
|
| 18 |
+
|
| 19 |
+
# Group by city
|
| 20 |
+
cities = {}
|
| 21 |
+
for person in data:
|
| 22 |
+
city = person["city"]
|
| 23 |
+
if city not in cities:
|
| 24 |
+
cities[city] = []
|
| 25 |
+
cities[city].append(person)
|
| 26 |
+
|
| 27 |
+
print("\nPeople by city:")
|
| 28 |
+
for city, people in cities.items():
|
| 29 |
+
print(f" {city}: {len(people)} people")
|
| 30 |
+
|
| 31 |
+
# Calculate average age
|
| 32 |
+
total_age = sum(person["age"] for person in data)
|
| 33 |
+
avg_age = total_age / len(data)
|
| 34 |
+
print(f"\nAverage age: {avg_age:.2f}")
|
| 35 |
+
|
| 36 |
+
# Find youngest and oldest
|
| 37 |
+
youngest = min(data, key=lambda x: x["age"])
|
| 38 |
+
oldest = max(data, key=lambda x: x["age"])
|
| 39 |
+
|
| 40 |
+
print(f"Youngest: {youngest['name']} ({youngest['age']})")
|
| 41 |
+
print(f"Oldest: {oldest['name']} ({oldest['age']})")
|
examples/python_stdin.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python Example: Input and Output
|
| 2 |
+
name = input("What is your name? ")
|
| 3 |
+
age = int(input("How old are you? "))
|
| 4 |
+
|
| 5 |
+
print(f"Hello {name}!")
|
| 6 |
+
print(f"You will be {age + 1} next year!")
|
| 7 |
+
|
| 8 |
+
# Calculate factorial
|
| 9 |
+
def factorial(n):
|
| 10 |
+
if n <= 1:
|
| 11 |
+
return 1
|
| 12 |
+
return n * factorial(n - 1)
|
| 13 |
+
|
| 14 |
+
print(f"Factorial of {age} is {factorial(age)}")
|
requirements-dev.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
pytest
|
| 2 |
+
pytest-asyncio
|
| 3 |
+
httpx
|
requirements.txt
CHANGED
|
@@ -1,2 +1,7 @@
|
|
| 1 |
fastapi
|
| 2 |
-
uvicorn[standard]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
fastapi
|
| 2 |
+
uvicorn[standard]
|
| 3 |
+
docker
|
| 4 |
+
pydantic
|
| 5 |
+
pydantic-settings
|
| 6 |
+
python-multipart
|
| 7 |
+
aiofiles
|
sandbox/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Sandbox package for isolated code execution
|
sandbox/container_builder.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import docker
|
| 2 |
+
from docker.errors import DockerException, ImageNotFound
|
| 3 |
+
import logging
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class ContainerBuilder:
|
| 10 |
+
"""Builds and manages the development environment Docker image"""
|
| 11 |
+
|
| 12 |
+
IMAGE_NAME = "sandbox-devenv"
|
| 13 |
+
IMAGE_TAG = "latest"
|
| 14 |
+
|
| 15 |
+
def __init__(self):
|
| 16 |
+
try:
|
| 17 |
+
self.client = docker.from_env()
|
| 18 |
+
logger.info("Container builder initialized")
|
| 19 |
+
except DockerException as e:
|
| 20 |
+
logger.error(f"Failed to initialize Docker client: {e}")
|
| 21 |
+
raise RuntimeError("Docker is not available") from e
|
| 22 |
+
|
| 23 |
+
def image_exists(self) -> bool:
|
| 24 |
+
"""Check if devenv image exists"""
|
| 25 |
+
try:
|
| 26 |
+
self.client.images.get(f"{self.IMAGE_NAME}:{self.IMAGE_TAG}")
|
| 27 |
+
return True
|
| 28 |
+
except ImageNotFound:
|
| 29 |
+
return False
|
| 30 |
+
|
| 31 |
+
def build_devenv_image(self) -> bool:
|
| 32 |
+
"""
|
| 33 |
+
Build the development environment image.
|
| 34 |
+
|
| 35 |
+
Returns:
|
| 36 |
+
True if successful, False otherwise
|
| 37 |
+
"""
|
| 38 |
+
try:
|
| 39 |
+
dockerfile_path = Path(__file__).parent / "images" / "devenv.Dockerfile"
|
| 40 |
+
|
| 41 |
+
if not dockerfile_path.exists():
|
| 42 |
+
logger.error(f"Dockerfile not found: {dockerfile_path}")
|
| 43 |
+
return False
|
| 44 |
+
|
| 45 |
+
logger.info("Building development environment image (this may take several minutes)...")
|
| 46 |
+
|
| 47 |
+
# Build image
|
| 48 |
+
image, build_logs = self.client.images.build(
|
| 49 |
+
path=str(dockerfile_path.parent),
|
| 50 |
+
dockerfile=str(dockerfile_path.name),
|
| 51 |
+
tag=f"{self.IMAGE_NAME}:{self.IMAGE_TAG}",
|
| 52 |
+
rm=True, # Remove intermediate containers
|
| 53 |
+
pull=True, # Pull base image
|
| 54 |
+
decode=True # Decode build logs
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
# Print build progress
|
| 58 |
+
for log in build_logs:
|
| 59 |
+
if 'stream' in log:
|
| 60 |
+
print(log['stream'], end='')
|
| 61 |
+
elif 'error' in log:
|
| 62 |
+
logger.error(f"Build error: {log['error']}")
|
| 63 |
+
return False
|
| 64 |
+
|
| 65 |
+
logger.info(f"Successfully built {self.IMAGE_NAME}:{self.IMAGE_TAG}")
|
| 66 |
+
return True
|
| 67 |
+
|
| 68 |
+
except Exception as e:
|
| 69 |
+
logger.error(f"Failed to build image: {e}", exc_info=True)
|
| 70 |
+
return False
|
| 71 |
+
|
| 72 |
+
def ensure_devenv_image(self) -> bool:
|
| 73 |
+
"""
|
| 74 |
+
Ensure devenv image exists, build if necessary.
|
| 75 |
+
|
| 76 |
+
Returns:
|
| 77 |
+
True if image is available, False otherwise
|
| 78 |
+
"""
|
| 79 |
+
if self.image_exists():
|
| 80 |
+
logger.info(f"Image {self.IMAGE_NAME}:{self.IMAGE_TAG} already exists")
|
| 81 |
+
return True
|
| 82 |
+
|
| 83 |
+
logger.info(f"Image {self.IMAGE_NAME}:{self.IMAGE_TAG} not found, building...")
|
| 84 |
+
return self.build_devenv_image()
|
sandbox/executor.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import docker
|
| 2 |
+
from docker.errors import DockerException, ContainerError, ImageNotFound, APIError
|
| 3 |
+
import time
|
| 4 |
+
import logging
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from sandbox.models import ExecutionRequest, ExecutionResponse, SandboxConfig, Language
|
| 7 |
+
from sandbox.language_runners import LanguageRunner
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class SandboxExecutor:
|
| 13 |
+
"""
|
| 14 |
+
Executes code in isolated Docker containers with security controls.
|
| 15 |
+
|
| 16 |
+
Features:
|
| 17 |
+
- Resource limits (CPU, memory)
|
| 18 |
+
- Network isolation
|
| 19 |
+
- Automatic cleanup
|
| 20 |
+
- Timeout enforcement
|
| 21 |
+
- Read-only filesystem
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
def __init__(self, config: Optional[SandboxConfig] = None):
|
| 25 |
+
self.config = config or SandboxConfig()
|
| 26 |
+
try:
|
| 27 |
+
self.client = docker.from_env()
|
| 28 |
+
# Test Docker connection
|
| 29 |
+
self.client.ping()
|
| 30 |
+
logger.info("Docker client initialized successfully")
|
| 31 |
+
except DockerException as e:
|
| 32 |
+
logger.error(f"Failed to initialize Docker client: {e}")
|
| 33 |
+
raise RuntimeError(
|
| 34 |
+
"Docker is not available. Please ensure Docker is running and accessible."
|
| 35 |
+
) from e
|
| 36 |
+
|
| 37 |
+
def _prepare_container_config(
|
| 38 |
+
self,
|
| 39 |
+
request: ExecutionRequest,
|
| 40 |
+
runner_config: dict
|
| 41 |
+
) -> dict:
|
| 42 |
+
"""Prepare Docker container configuration with security settings"""
|
| 43 |
+
|
| 44 |
+
# Calculate resource limits
|
| 45 |
+
memory_limit = f"{request.memory_limit}m"
|
| 46 |
+
cpu_quota = 50000 # 0.5 CPU cores (out of 100000)
|
| 47 |
+
|
| 48 |
+
container_config = {
|
| 49 |
+
"image": runner_config["image"],
|
| 50 |
+
"command": runner_config["command"] + [request.code],
|
| 51 |
+
"stdin_open": bool(request.stdin),
|
| 52 |
+
"detach": True,
|
| 53 |
+
"remove": False, # We'll remove manually after getting logs
|
| 54 |
+
|
| 55 |
+
# Security settings
|
| 56 |
+
"network_mode": "none" if not self.config.enable_network else "bridge",
|
| 57 |
+
"read_only": self.config.read_only_root,
|
| 58 |
+
"security_opt": ["no-new-privileges"],
|
| 59 |
+
|
| 60 |
+
# Resource limits
|
| 61 |
+
"mem_limit": memory_limit,
|
| 62 |
+
"memswap_limit": memory_limit, # Disable swap
|
| 63 |
+
"cpu_quota": cpu_quota,
|
| 64 |
+
"cpu_period": 100000,
|
| 65 |
+
|
| 66 |
+
# Prevent fork bombs
|
| 67 |
+
"pids_limit": 50,
|
| 68 |
+
|
| 69 |
+
# Working directory
|
| 70 |
+
"working_dir": "/tmp",
|
| 71 |
+
|
| 72 |
+
# User (non-root when possible)
|
| 73 |
+
"user": "nobody" if runner_config["image"] != "bash:5.2-alpine" else None,
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
return container_config
|
| 77 |
+
|
| 78 |
+
def execute(self, request: ExecutionRequest) -> ExecutionResponse:
|
| 79 |
+
"""
|
| 80 |
+
Execute code in an isolated container.
|
| 81 |
+
|
| 82 |
+
Args:
|
| 83 |
+
request: Execution request with code and parameters
|
| 84 |
+
|
| 85 |
+
Returns:
|
| 86 |
+
ExecutionResponse with stdout, stderr, and execution metadata
|
| 87 |
+
"""
|
| 88 |
+
start_time = time.time()
|
| 89 |
+
container = None
|
| 90 |
+
|
| 91 |
+
try:
|
| 92 |
+
# Get language-specific configuration
|
| 93 |
+
runner_config = LanguageRunner.get_runner_config(request.language)
|
| 94 |
+
|
| 95 |
+
# Prepare container configuration
|
| 96 |
+
container_config = self._prepare_container_config(request, runner_config)
|
| 97 |
+
|
| 98 |
+
# Pull image if not available
|
| 99 |
+
try:
|
| 100 |
+
self.client.images.get(runner_config["image"])
|
| 101 |
+
except ImageNotFound:
|
| 102 |
+
logger.info(f"Pulling image {runner_config['image']}...")
|
| 103 |
+
self.client.images.pull(runner_config["image"])
|
| 104 |
+
|
| 105 |
+
# Create and start container
|
| 106 |
+
logger.info(f"Executing {request.language} code in container")
|
| 107 |
+
container = self.client.containers.create(**container_config)
|
| 108 |
+
container.start()
|
| 109 |
+
|
| 110 |
+
# Provide stdin if specified
|
| 111 |
+
if request.stdin:
|
| 112 |
+
sock = container.attach_socket(params={'stdin': 1, 'stream': 1})
|
| 113 |
+
sock._sock.sendall(request.stdin.encode())
|
| 114 |
+
sock.close()
|
| 115 |
+
|
| 116 |
+
# Wait for container to finish with timeout
|
| 117 |
+
try:
|
| 118 |
+
result = container.wait(timeout=request.timeout)
|
| 119 |
+
exit_code = result.get("StatusCode", -1)
|
| 120 |
+
error_msg = None
|
| 121 |
+
except Exception as e:
|
| 122 |
+
# Timeout or other error
|
| 123 |
+
logger.warning(f"Container execution timeout or error: {e}")
|
| 124 |
+
container.stop(timeout=1)
|
| 125 |
+
exit_code = 124 # Timeout exit code
|
| 126 |
+
error_msg = f"Execution timed out after {request.timeout} seconds"
|
| 127 |
+
|
| 128 |
+
# Get logs (stdout and stderr combined)
|
| 129 |
+
try:
|
| 130 |
+
logs = container.logs(stdout=True, stderr=True).decode('utf-8', errors='replace')
|
| 131 |
+
|
| 132 |
+
# Truncate if too large
|
| 133 |
+
if len(logs) > self.config.max_output_size:
|
| 134 |
+
logs = logs[:self.config.max_output_size] + "\n... (output truncated)"
|
| 135 |
+
|
| 136 |
+
# Try to separate stdout and stderr (Docker combines them)
|
| 137 |
+
stdout = logs
|
| 138 |
+
stderr = ""
|
| 139 |
+
|
| 140 |
+
# If there's an error, try to extract stderr
|
| 141 |
+
if exit_code != 0:
|
| 142 |
+
stderr = logs
|
| 143 |
+
stdout = ""
|
| 144 |
+
|
| 145 |
+
except Exception as e:
|
| 146 |
+
logger.error(f"Failed to get container logs: {e}")
|
| 147 |
+
stdout = ""
|
| 148 |
+
stderr = str(e)
|
| 149 |
+
|
| 150 |
+
execution_time = time.time() - start_time
|
| 151 |
+
|
| 152 |
+
return ExecutionResponse(
|
| 153 |
+
stdout=stdout,
|
| 154 |
+
stderr=stderr,
|
| 155 |
+
exit_code=exit_code,
|
| 156 |
+
execution_time=round(execution_time, 3),
|
| 157 |
+
error=error_msg
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
except ImageNotFound as e:
|
| 161 |
+
logger.error(f"Image not found: {e}")
|
| 162 |
+
return ExecutionResponse(
|
| 163 |
+
stdout="",
|
| 164 |
+
stderr="",
|
| 165 |
+
exit_code=-1,
|
| 166 |
+
execution_time=time.time() - start_time,
|
| 167 |
+
error=f"Language image not available: {runner_config['image']}"
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
except ContainerError as e:
|
| 171 |
+
logger.error(f"Container error: {e}")
|
| 172 |
+
return ExecutionResponse(
|
| 173 |
+
stdout="",
|
| 174 |
+
stderr=str(e),
|
| 175 |
+
exit_code=e.exit_status,
|
| 176 |
+
execution_time=time.time() - start_time,
|
| 177 |
+
error="Container execution failed"
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
except APIError as e:
|
| 181 |
+
logger.error(f"Docker API error: {e}")
|
| 182 |
+
return ExecutionResponse(
|
| 183 |
+
stdout="",
|
| 184 |
+
stderr="",
|
| 185 |
+
exit_code=-1,
|
| 186 |
+
execution_time=time.time() - start_time,
|
| 187 |
+
error=f"Docker API error: {str(e)}"
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
except Exception as e:
|
| 191 |
+
logger.error(f"Unexpected error during execution: {e}", exc_info=True)
|
| 192 |
+
return ExecutionResponse(
|
| 193 |
+
stdout="",
|
| 194 |
+
stderr="",
|
| 195 |
+
exit_code=-1,
|
| 196 |
+
execution_time=time.time() - start_time,
|
| 197 |
+
error=f"Unexpected error: {str(e)}"
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
finally:
|
| 201 |
+
# Always cleanup container
|
| 202 |
+
if container:
|
| 203 |
+
try:
|
| 204 |
+
container.remove(force=True)
|
| 205 |
+
logger.debug(f"Container {container.id[:12]} removed")
|
| 206 |
+
except Exception as e:
|
| 207 |
+
logger.warning(f"Failed to remove container: {e}")
|
| 208 |
+
|
| 209 |
+
def cleanup_all(self):
|
| 210 |
+
"""Remove all stopped containers (maintenance task)"""
|
| 211 |
+
try:
|
| 212 |
+
containers = self.client.containers.list(all=True, filters={"status": "exited"})
|
| 213 |
+
for container in containers:
|
| 214 |
+
try:
|
| 215 |
+
container.remove()
|
| 216 |
+
logger.info(f"Cleaned up container {container.id[:12]}")
|
| 217 |
+
except Exception as e:
|
| 218 |
+
logger.warning(f"Failed to remove container {container.id[:12]}: {e}")
|
| 219 |
+
except Exception as e:
|
| 220 |
+
logger.error(f"Failed to cleanup containers: {e}")
|
sandbox/file_manager.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import docker
|
| 2 |
+
from docker.errors import DockerException, NotFound
|
| 3 |
+
import os
|
| 4 |
+
import logging
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from typing import Optional, List, BinaryIO
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
import mimetypes
|
| 9 |
+
|
| 10 |
+
from sandbox.models import FileInfo, FileUploadResponse
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class FileManager:
|
| 16 |
+
"""
|
| 17 |
+
Manages file operations within session containers.
|
| 18 |
+
|
| 19 |
+
Handles:
|
| 20 |
+
- File uploads to session volumes
|
| 21 |
+
- File downloads from sessions
|
| 22 |
+
- Directory listing
|
| 23 |
+
- File deletion
|
| 24 |
+
- Path validation and security
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
# Security settings
|
| 28 |
+
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
| 29 |
+
MAX_SESSION_SIZE = 100 * 1024 * 1024 # 100MB
|
| 30 |
+
|
| 31 |
+
ALLOWED_EXTENSIONS = {
|
| 32 |
+
'.py', '.js', '.ts', '.java', '.go', '.rs',
|
| 33 |
+
'.sh', '.bash', '.txt', '.md', '.json',
|
| 34 |
+
'.yaml', '.yml', '.toml', '.xml', '.html',
|
| 35 |
+
'.css', '.c', '.cpp', '.h', '.hpp'
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
def __init__(self):
|
| 39 |
+
try:
|
| 40 |
+
self.client = docker.from_env()
|
| 41 |
+
logger.info("File manager initialized")
|
| 42 |
+
except DockerException as e:
|
| 43 |
+
logger.error(f"Failed to initialize Docker client: {e}")
|
| 44 |
+
raise RuntimeError("Docker is not available") from e
|
| 45 |
+
|
| 46 |
+
def _validate_path(self, filepath: str) -> str:
|
| 47 |
+
"""
|
| 48 |
+
Validate and sanitize file path.
|
| 49 |
+
|
| 50 |
+
Prevents path traversal attacks.
|
| 51 |
+
"""
|
| 52 |
+
# Remove any path traversal attempts
|
| 53 |
+
clean_path = os.path.normpath(filepath).lstrip('/')
|
| 54 |
+
|
| 55 |
+
# Ensure it doesn't escape workspace
|
| 56 |
+
if clean_path.startswith('..') or '/../' in clean_path:
|
| 57 |
+
raise ValueError("Path traversal detected")
|
| 58 |
+
|
| 59 |
+
return f"/workspace/{clean_path}"
|
| 60 |
+
|
| 61 |
+
def _validate_extension(self, filename: str) -> bool:
|
| 62 |
+
"""Check if file extension is allowed"""
|
| 63 |
+
ext = os.path.splitext(filename)[1].lower()
|
| 64 |
+
return ext in self.ALLOWED_EXTENSIONS or ext == ''
|
| 65 |
+
|
| 66 |
+
def upload_file(
|
| 67 |
+
self,
|
| 68 |
+
container_id: str,
|
| 69 |
+
filename: str,
|
| 70 |
+
file_data: bytes
|
| 71 |
+
) -> FileUploadResponse:
|
| 72 |
+
"""
|
| 73 |
+
Upload file to session container.
|
| 74 |
+
|
| 75 |
+
Args:
|
| 76 |
+
container_id: Target container ID
|
| 77 |
+
filename: Name of file to create
|
| 78 |
+
file_data: File binary data
|
| 79 |
+
|
| 80 |
+
Returns:
|
| 81 |
+
FileUploadResponse with file details
|
| 82 |
+
"""
|
| 83 |
+
# Validate file size
|
| 84 |
+
if len(file_data) > self.MAX_FILE_SIZE:
|
| 85 |
+
raise ValueError(f"File size exceeds limit of {self.MAX_FILE_SIZE} bytes")
|
| 86 |
+
|
| 87 |
+
# Validate filename
|
| 88 |
+
if not filename or '/' in filename or '\\\\' in filename:
|
| 89 |
+
raise ValueError("Invalid filename")
|
| 90 |
+
|
| 91 |
+
# Validate extension
|
| 92 |
+
if not self._validate_extension(filename):
|
| 93 |
+
raise ValueError(f"File extension not allowed. Allowed: {', '.join(self.ALLOWED_EXTENSIONS)}")
|
| 94 |
+
|
| 95 |
+
try:
|
| 96 |
+
container = self.client.containers.get(container_id)
|
| 97 |
+
|
| 98 |
+
# Create file path
|
| 99 |
+
file_path = f"/workspace/{filename}"
|
| 100 |
+
|
| 101 |
+
# Write file using docker exec
|
| 102 |
+
# Create temp directory and write file
|
| 103 |
+
import tarfile
|
| 104 |
+
import io
|
| 105 |
+
|
| 106 |
+
# Create tar archive in memory
|
| 107 |
+
tar_stream = io.BytesIO()
|
| 108 |
+
tar = tarfile.open(fileobj=tar_stream, mode='w')
|
| 109 |
+
|
| 110 |
+
# Create file info
|
| 111 |
+
tarinfo = tarfile.TarInfo(name=filename)
|
| 112 |
+
tarinfo.size = len(file_data)
|
| 113 |
+
tarinfo.mtime = datetime.utcnow().timestamp()
|
| 114 |
+
|
| 115 |
+
# Add file to tar
|
| 116 |
+
tar.addfile(tarinfo, io.BytesIO(file_data))
|
| 117 |
+
tar.close()
|
| 118 |
+
|
| 119 |
+
# Upload tar to container
|
| 120 |
+
tar_stream.seek(0)
|
| 121 |
+
container.put_archive('/workspace', tar_stream)
|
| 122 |
+
|
| 123 |
+
logger.info(f"Uploaded file {filename} to container {container_id[:12]}")
|
| 124 |
+
|
| 125 |
+
# Detect MIME type
|
| 126 |
+
mime_type, _ = mimetypes.guess_type(filename)
|
| 127 |
+
|
| 128 |
+
return FileUploadResponse(
|
| 129 |
+
filename=filename,
|
| 130 |
+
path=file_path,
|
| 131 |
+
size=len(file_data),
|
| 132 |
+
message=f"File '{filename}' uploaded successfully"
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
except NotFound:
|
| 136 |
+
raise ValueError(f"Container {container_id} not found")
|
| 137 |
+
except Exception as e:
|
| 138 |
+
logger.error(f"Error uploading file: {e}", exc_info=True)
|
| 139 |
+
raise RuntimeError(f"Failed to upload file: {str(e)}")
|
| 140 |
+
|
| 141 |
+
def download_file(self, container_id: str, filepath: str) -> bytes:
|
| 142 |
+
"""
|
| 143 |
+
Download file from session container.
|
| 144 |
+
|
| 145 |
+
Args:
|
| 146 |
+
container_id: Source container ID
|
| 147 |
+
filepath: Path to file (relative to /workspace)
|
| 148 |
+
|
| 149 |
+
Returns:
|
| 150 |
+
File binary data
|
| 151 |
+
"""
|
| 152 |
+
# Validate and sanitize path
|
| 153 |
+
safe_path = self._validate_path(filepath)
|
| 154 |
+
|
| 155 |
+
try:
|
| 156 |
+
container = self.client.containers.get(container_id)
|
| 157 |
+
|
| 158 |
+
# Get file as tar stream
|
| 159 |
+
bits, stat = container.get_archive(safe_path)
|
| 160 |
+
|
| 161 |
+
# Extract file from tar
|
| 162 |
+
import tarfile
|
| 163 |
+
import io
|
| 164 |
+
|
| 165 |
+
tar_stream = io.BytesIO()
|
| 166 |
+
for chunk in bits:
|
| 167 |
+
tar_stream.write(chunk)
|
| 168 |
+
tar_stream.seek(0)
|
| 169 |
+
|
| 170 |
+
tar = tarfile.open(fileobj=tar_stream)
|
| 171 |
+
|
| 172 |
+
# Get first member (the file)
|
| 173 |
+
member = tar.getmembers()[0]
|
| 174 |
+
file_obj = tar.extractfile(member)
|
| 175 |
+
file_data = file_obj.read()
|
| 176 |
+
|
| 177 |
+
logger.info(f"Downloaded file {filepath} from container {container_id[:12]}")
|
| 178 |
+
return file_data
|
| 179 |
+
|
| 180 |
+
except NotFound:
|
| 181 |
+
raise ValueError(f"File {filepath} not found in container")
|
| 182 |
+
except Exception as e:
|
| 183 |
+
logger.error(f"Error downloading file: {e}", exc_info=True)
|
| 184 |
+
raise RuntimeError(f"Failed to download file: {str(e)}")
|
| 185 |
+
|
| 186 |
+
def list_files(self, container_id: str, directory: str = "/workspace") -> List[FileInfo]:
|
| 187 |
+
"""
|
| 188 |
+
List files in session directory.
|
| 189 |
+
|
| 190 |
+
Args:
|
| 191 |
+
container_id: Container ID
|
| 192 |
+
directory: Directory to list
|
| 193 |
+
|
| 194 |
+
Returns:
|
| 195 |
+
List of FileInfo objects
|
| 196 |
+
"""
|
| 197 |
+
# Validate path
|
| 198 |
+
safe_dir = self._validate_path(directory)
|
| 199 |
+
|
| 200 |
+
try:
|
| 201 |
+
container = self.client.containers.get(container_id)
|
| 202 |
+
|
| 203 |
+
# Execute ls command
|
| 204 |
+
result = container.exec_run(
|
| 205 |
+
f"find {safe_dir} -maxdepth 1 -type f -exec stat -c '%n|%s|%Y' {{}} \\;",
|
| 206 |
+
demux=True
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
if result.exit_code != 0:
|
| 210 |
+
logger.warning(f"Failed to list files: {result.output}")
|
| 211 |
+
return []
|
| 212 |
+
|
| 213 |
+
stdout = result.output[0].decode('utf-8') if result.output[0] else ""
|
| 214 |
+
|
| 215 |
+
files = []
|
| 216 |
+
for line in stdout.strip().split('\\n'):
|
| 217 |
+
if not line:
|
| 218 |
+
continue
|
| 219 |
+
|
| 220 |
+
try:
|
| 221 |
+
path, size, mtime = line.split('|')
|
| 222 |
+
filename = os.path.basename(path)
|
| 223 |
+
|
| 224 |
+
# Get MIME type
|
| 225 |
+
mime_type, _ = mimetypes.guess_type(filename)
|
| 226 |
+
|
| 227 |
+
files.append(FileInfo(
|
| 228 |
+
filename=filename,
|
| 229 |
+
path=path,
|
| 230 |
+
size=int(size),
|
| 231 |
+
modified_at=datetime.fromtimestamp(int(mtime)),
|
| 232 |
+
mime_type=mime_type or 'application/octet-stream'
|
| 233 |
+
))
|
| 234 |
+
except Exception as e:
|
| 235 |
+
logger.warning(f"Failed to parse file info: {line}, error: {e}")
|
| 236 |
+
continue
|
| 237 |
+
|
| 238 |
+
return files
|
| 239 |
+
|
| 240 |
+
except NotFound:
|
| 241 |
+
raise ValueError(f"Container {container_id} not found")
|
| 242 |
+
except Exception as e:
|
| 243 |
+
logger.error(f"Error listing files: {e}", exc_info=True)
|
| 244 |
+
return []
|
| 245 |
+
|
| 246 |
+
def delete_file(self, container_id: str, filepath: str) -> bool:
|
| 247 |
+
"""
|
| 248 |
+
Delete file from session.
|
| 249 |
+
|
| 250 |
+
Args:
|
| 251 |
+
container_id: Container ID
|
| 252 |
+
filepath: Path to file
|
| 253 |
+
|
| 254 |
+
Returns:
|
| 255 |
+
True if deleted successfully
|
| 256 |
+
"""
|
| 257 |
+
# Validate path
|
| 258 |
+
safe_path = self._validate_path(filepath)
|
| 259 |
+
|
| 260 |
+
try:
|
| 261 |
+
container = self.client.containers.get(container_id)
|
| 262 |
+
|
| 263 |
+
# Execute rm command
|
| 264 |
+
result = container.exec_run(f"rm {safe_path}")
|
| 265 |
+
|
| 266 |
+
if result.exit_code == 0:
|
| 267 |
+
logger.info(f"Deleted file {filepath} from container {container_id[:12]}")
|
| 268 |
+
return True
|
| 269 |
+
else:
|
| 270 |
+
logger.warning(f"Failed to delete file: {result.output}")
|
| 271 |
+
return False
|
| 272 |
+
|
| 273 |
+
except NotFound:
|
| 274 |
+
raise ValueError(f"Container {container_id} not found")
|
| 275 |
+
except Exception as e:
|
| 276 |
+
logger.error(f"Error deleting file: {e}", exc_info=True)
|
| 277 |
+
return False
|
sandbox/images/bash.Dockerfile
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Minimal, hardened Bash execution image
|
| 2 |
+
FROM bash:5.2-alpine
|
| 3 |
+
|
| 4 |
+
# Install security updates and minimal utilities
|
| 5 |
+
RUN apk update && apk upgrade && \
|
| 6 |
+
apk add --no-cache \
|
| 7 |
+
coreutils \
|
| 8 |
+
procps \
|
| 9 |
+
net-tools \
|
| 10 |
+
curl \
|
| 11 |
+
&& \
|
| 12 |
+
rm -rf /var/cache/apk/*
|
| 13 |
+
|
| 14 |
+
# Create non-root user
|
| 15 |
+
RUN adduser -D -s /bin/bash sandbox
|
| 16 |
+
|
| 17 |
+
# Set working directory
|
| 18 |
+
WORKDIR /sandbox
|
| 19 |
+
|
| 20 |
+
# Change ownership to sandbox user
|
| 21 |
+
RUN chown -R sandbox:sandbox /sandbox
|
| 22 |
+
|
| 23 |
+
# Switch to non-root user
|
| 24 |
+
USER sandbox
|
| 25 |
+
|
| 26 |
+
# Default command
|
| 27 |
+
CMD ["bash"]
|
sandbox/images/devenv.Dockerfile
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Multi-Language Development Environment
|
| 2 |
+
# Based on Ubuntu 24.04 with Python, Node.js, Java, Go, Rust, Docker
|
| 3 |
+
|
| 4 |
+
FROM ubuntu:24.04
|
| 5 |
+
|
| 6 |
+
ENV DEBIAN_FRONTEND=noninteractive
|
| 7 |
+
ENV TZ=UTC
|
| 8 |
+
|
| 9 |
+
# Install base utilities
|
| 10 |
+
RUN apt-get update && apt-get install -y \\
|
| 11 |
+
curl wget git build-essential ca-certificates gnupg lsb-release \\
|
| 12 |
+
software-properties-common apt-transport-https \\
|
| 13 |
+
sudo vim nano tmux \\
|
| 14 |
+
jq ripgrep fd-find bat \\
|
| 15 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 16 |
+
|
| 17 |
+
# ========== Python Environment ==========
|
| 18 |
+
RUN apt-get update && apt-get install -y \\
|
| 19 |
+
python3.12 python3.12-dev python3.12-venv \\
|
| 20 |
+
python3-pip pipx \\
|
| 21 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 22 |
+
|
| 23 |
+
# Install pyenv for Python version management
|
| 24 |
+
RUN curl https://pyenv.run | bash
|
| 25 |
+
ENV PATH="/root/.pyenv/bin:${PATH}"
|
| 26 |
+
RUN echo 'eval "$(pyenv init -)"' >> ~/.bashrc
|
| 27 |
+
|
| 28 |
+
# Install Poetry
|
| 29 |
+
RUN pipx install poetry && pipx ensurepath
|
| 30 |
+
|
| 31 |
+
# Install Python tools
|
| 32 |
+
RUN pip3 install --no-cache-dir --break-system-packages \\
|
| 33 |
+
uv black mypy pytest ruff
|
| 34 |
+
|
| 35 |
+
# ========== Node.js Environment ==========
|
| 36 |
+
# Install nvm
|
| 37 |
+
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash
|
| 38 |
+
ENV NVM_DIR="/root/.nvm"
|
| 39 |
+
RUN . "$NVM_DIR/nvm.sh" && \\
|
| 40 |
+
nvm install 22 && \\
|
| 41 |
+
nvm install 20 && \\
|
| 42 |
+
nvm install 18 && \\
|
| 43 |
+
nvm alias default 22 && \\
|
| 44 |
+
nvm use 22
|
| 45 |
+
|
| 46 |
+
# Install npm global tools
|
| 47 |
+
RUN . "$NVM_DIR/nvm.sh" && \\
|
| 48 |
+
npm install -g yarn pnpm eslint prettier
|
| 49 |
+
|
| 50 |
+
# ========== Java Environment ==========
|
| 51 |
+
RUN apt-get update && apt-get install -y \\
|
| 52 |
+
openjdk-21-jdk maven gradle \\
|
| 53 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 54 |
+
|
| 55 |
+
# ========== Go Environment ==========
|
| 56 |
+
RUN wget https://go.dev/dl/go1.24.3.linux-amd64.tar.gz && \\
|
| 57 |
+
tar -C /usr/local -xzf go1.24.3.linux-amd64.tar.gz && \\
|
| 58 |
+
rm go1.24.3.linux-amd64.tar.gz
|
| 59 |
+
ENV PATH="/usr/local/go/bin:${PATH}"
|
| 60 |
+
ENV GOPATH="/root/go"
|
| 61 |
+
ENV PATH="${GOPATH}/bin:${PATH}"
|
| 62 |
+
|
| 63 |
+
# ========== Rust Environment ==========
|
| 64 |
+
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
| 65 |
+
ENV PATH="/root/.cargo/bin:${PATH}"
|
| 66 |
+
|
| 67 |
+
# ========== Docker-in-Docker ==========
|
| 68 |
+
RUN curl -fsSL https://get.docker.com -o get-docker.sh && \\
|
| 69 |
+
sh get-docker.sh && \\
|
| 70 |
+
rm get-docker.sh
|
| 71 |
+
|
| 72 |
+
# ========== C/C++ Compilers ==========
|
| 73 |
+
RUN apt-get update && apt-get install -y \\
|
| 74 |
+
clang gcc g++ cmake ninja-build \\
|
| 75 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 76 |
+
|
| 77 |
+
# Install Conan
|
| 78 |
+
RUN pip3 install --no-cache-dir --break-system-packages conan
|
| 79 |
+
|
| 80 |
+
# ========== Other Utilities ==========
|
| 81 |
+
RUN apt-get update && apt-get install -y \\
|
| 82 |
+
gawk sed grep tar gzip bzip2 xz-utils \\
|
| 83 |
+
make automake autoconf \\
|
| 84 |
+
openssh-client \\
|
| 85 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 86 |
+
|
| 87 |
+
# Install yq
|
| 88 |
+
RUN wget https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 -O /usr/bin/yq && \\
|
| 89 |
+
chmod +x /usr/bin/yq
|
| 90 |
+
|
| 91 |
+
# Create developer user
|
| 92 |
+
RUN useradd -m -u 1000 -G sudo developer && \\
|
| 93 |
+
echo "developer:developer" | chpasswd && \\
|
| 94 |
+
echo "developer ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
|
| 95 |
+
|
| 96 |
+
# Create workspace
|
| 97 |
+
RUN mkdir -p /workspace && chown developer:developer /workspace
|
| 98 |
+
|
| 99 |
+
# Copy environment check script
|
| 100 |
+
COPY environment_check.sh /opt/environment_check.sh
|
| 101 |
+
RUN chmod +x /opt/environment_check.sh
|
| 102 |
+
|
| 103 |
+
# Set default user
|
| 104 |
+
USER developer
|
| 105 |
+
WORKDIR /workspace
|
| 106 |
+
|
| 107 |
+
# Default command (keep container running)
|
| 108 |
+
CMD ["tail", "-f", "/dev/null"]
|
sandbox/images/environment_check.sh
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Environment validation script
|
| 3 |
+
|
| 4 |
+
echo "============================="
|
| 5 |
+
echo "Environment Check"
|
| 6 |
+
echo "============================="
|
| 7 |
+
echo ""
|
| 8 |
+
|
| 9 |
+
# Colors for output
|
| 10 |
+
GREEN='\\033[0;32m'
|
| 11 |
+
RED='\\033[0;31m'
|
| 12 |
+
NC='\\033[0m' # No Color
|
| 13 |
+
|
| 14 |
+
check_command() {
|
| 15 |
+
if command -v $1 &> /dev/null; then
|
| 16 |
+
echo -e "${GREEN}✅${NC} $1: $($@)"
|
| 17 |
+
else
|
| 18 |
+
echo -e "${RED}❌${NC} $1: not found"
|
| 19 |
+
fi
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
echo "-------- Python --------"
|
| 23 |
+
check_command python3 --version
|
| 24 |
+
check_command python --version
|
| 25 |
+
check_command pip3 --version
|
| 26 |
+
check_command pipx --version
|
| 27 |
+
check_command poetry --version
|
| 28 |
+
check_command uv --version
|
| 29 |
+
check_command black --version
|
| 30 |
+
check_command mypy --version
|
| 31 |
+
check_command pytest --version
|
| 32 |
+
check_command ruff --version
|
| 33 |
+
echo ""
|
| 34 |
+
|
| 35 |
+
echo "-------- NodeJS --------"
|
| 36 |
+
if [ -s "$NVM_DIR/nvm.sh" ]; then
|
| 37 |
+
. "$NVM_DIR/nvm.sh"
|
| 38 |
+
check_command node --version
|
| 39 |
+
nvm list
|
| 40 |
+
else
|
| 41 |
+
check_command node --version
|
| 42 |
+
fi
|
| 43 |
+
check_command npm --version
|
| 44 |
+
check_command yarn --version
|
| 45 |
+
check_command pnpm --version
|
| 46 |
+
check_command eslint --version
|
| 47 |
+
check_command prettier --version
|
| 48 |
+
echo ""
|
| 49 |
+
|
| 50 |
+
echo "-------- Java --------"
|
| 51 |
+
check_command java --version
|
| 52 |
+
check_command mvn --version
|
| 53 |
+
check_command gradle --version
|
| 54 |
+
echo ""
|
| 55 |
+
|
| 56 |
+
echo "-------- Go --------"
|
| 57 |
+
check_command go version
|
| 58 |
+
echo ""
|
| 59 |
+
|
| 60 |
+
echo "-------- Rust --------"
|
| 61 |
+
check_command rustc --version
|
| 62 |
+
check_command cargo --version
|
| 63 |
+
echo ""
|
| 64 |
+
|
| 65 |
+
echo "-------- C/C++ Compilers --------"
|
| 66 |
+
check_command clang --version
|
| 67 |
+
check_command gcc --version
|
| 68 |
+
check_command cmake --version
|
| 69 |
+
check_command ninja --version
|
| 70 |
+
check_command conan --version
|
| 71 |
+
echo ""
|
| 72 |
+
|
| 73 |
+
echo "-------- Docker --------"
|
| 74 |
+
check_command docker --version
|
| 75 |
+
check_command docker compose version
|
| 76 |
+
echo ""
|
| 77 |
+
|
| 78 |
+
echo "-------- Other Utilities --------"
|
| 79 |
+
check_command awk --version
|
| 80 |
+
check_command curl --version
|
| 81 |
+
check_command git --version
|
| 82 |
+
check_command grep --version
|
| 83 |
+
check_command gzip --version
|
| 84 |
+
check_command jq --version
|
| 85 |
+
check_command make --version
|
| 86 |
+
check_command rg --version
|
| 87 |
+
check_command sed --version
|
| 88 |
+
check_command tar --version
|
| 89 |
+
check_command tmux -V
|
| 90 |
+
check_command yq --version
|
| 91 |
+
echo ""
|
| 92 |
+
|
| 93 |
+
echo "============================="
|
| 94 |
+
echo "Environment check complete!"
|
| 95 |
+
echo "============================="
|
sandbox/images/javascript.Dockerfile
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Minimal, hardened Node.js execution image
|
| 2 |
+
FROM node:20-alpine
|
| 3 |
+
|
| 4 |
+
# Install security updates and minimal dependencies
|
| 5 |
+
RUN apk update && apk upgrade && \
|
| 6 |
+
apk add --no-cache \
|
| 7 |
+
bash \
|
| 8 |
+
curl \
|
| 9 |
+
&& \
|
| 10 |
+
rm -rf /var/cache/apk/*
|
| 11 |
+
|
| 12 |
+
# Create non-root user
|
| 13 |
+
RUN addgroup -g 1001 -S nodejs && \
|
| 14 |
+
adduser -S nodejs -u 1001
|
| 15 |
+
|
| 16 |
+
# Set working directory
|
| 17 |
+
WORKDIR /sandbox
|
| 18 |
+
|
| 19 |
+
# Change ownership to nodejs user
|
| 20 |
+
RUN chown -R nodejs:nodejs /sandbox
|
| 21 |
+
|
| 22 |
+
# Switch to non-root user
|
| 23 |
+
USER nodejs
|
| 24 |
+
|
| 25 |
+
# Default command
|
| 26 |
+
CMD ["node"]
|
sandbox/images/python.Dockerfile
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Minimal, hardened Python execution image
|
| 2 |
+
FROM python:3.11-slim-alpine
|
| 3 |
+
|
| 4 |
+
# Install security updates and minimal dependencies
|
| 5 |
+
RUN apk update && apk upgrade && \
|
| 6 |
+
apk add --no-cache \
|
| 7 |
+
bash \
|
| 8 |
+
&& \
|
| 9 |
+
rm -rf /var/cache/apk/*
|
| 10 |
+
|
| 11 |
+
# Create non-root user
|
| 12 |
+
RUN adduser -D -s /bin/bash sandbox
|
| 13 |
+
|
| 14 |
+
# Set working directory
|
| 15 |
+
WORKDIR /sandbox
|
| 16 |
+
|
| 17 |
+
# Switch to non-root user
|
| 18 |
+
USER sandbox
|
| 19 |
+
|
| 20 |
+
# Default command
|
| 21 |
+
CMD ["python3"]
|
sandbox/language_runners.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sandbox.models import Language, LanguageInfo
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class LanguageRunner:
|
| 5 |
+
"""Base configuration for language runners"""
|
| 6 |
+
|
| 7 |
+
@staticmethod
|
| 8 |
+
def get_runner_config(language: Language) -> dict:
|
| 9 |
+
"""Get Docker configuration for a specific language"""
|
| 10 |
+
configs = {
|
| 11 |
+
Language.PYTHON: {
|
| 12 |
+
"image": "python:3.11-slim",
|
| 13 |
+
"command": ["python", "-c"],
|
| 14 |
+
"version": "3.11",
|
| 15 |
+
"extensions": [".py"],
|
| 16 |
+
},
|
| 17 |
+
Language.JAVASCRIPT: {
|
| 18 |
+
"image": "node:20-alpine",
|
| 19 |
+
"command": ["node", "-e"],
|
| 20 |
+
"version": "20",
|
| 21 |
+
"extensions": [".js"],
|
| 22 |
+
},
|
| 23 |
+
Language.BASH: {
|
| 24 |
+
"image": "bash:5.2-alpine",
|
| 25 |
+
"command": ["bash", "-c"],
|
| 26 |
+
"version": "5.2",
|
| 27 |
+
"extensions": [".sh"],
|
| 28 |
+
},
|
| 29 |
+
}
|
| 30 |
+
return configs.get(language, configs[Language.PYTHON])
|
| 31 |
+
|
| 32 |
+
@staticmethod
|
| 33 |
+
def get_all_languages() -> list[LanguageInfo]:
|
| 34 |
+
"""Get information about all supported languages"""
|
| 35 |
+
return [
|
| 36 |
+
LanguageInfo(
|
| 37 |
+
name="Python",
|
| 38 |
+
version="3.11",
|
| 39 |
+
image="python:3.11-slim",
|
| 40 |
+
extensions=[".py"]
|
| 41 |
+
),
|
| 42 |
+
LanguageInfo(
|
| 43 |
+
name="JavaScript",
|
| 44 |
+
version="20",
|
| 45 |
+
image="node:20-alpine",
|
| 46 |
+
extensions=[".js"]
|
| 47 |
+
),
|
| 48 |
+
LanguageInfo(
|
| 49 |
+
name="Bash",
|
| 50 |
+
version="5.2",
|
| 51 |
+
image="bash:5.2-alpine",
|
| 52 |
+
extensions=[".sh"]
|
| 53 |
+
),
|
| 54 |
+
]
|
sandbox/models.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field, field_validator
|
| 2 |
+
from typing import Optional, Literal, Dict, Any
|
| 3 |
+
from enum import Enum
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
import uuid
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class Language(str, Enum):
|
| 9 |
+
"""Supported programming languages"""
|
| 10 |
+
PYTHON = "python"
|
| 11 |
+
JAVASCRIPT = "javascript"
|
| 12 |
+
BASH = "bash"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ExecutionRequest(BaseModel):
|
| 16 |
+
"""Request model for code execution"""
|
| 17 |
+
code: str = Field(..., description="Code to execute", min_length=1, max_length=50000)
|
| 18 |
+
language: Language = Field(..., description="Programming language")
|
| 19 |
+
stdin: str = Field(default="", description="Standard input for the code")
|
| 20 |
+
timeout: int = Field(default=10, ge=1, le=30, description="Execution timeout in seconds")
|
| 21 |
+
memory_limit: int = Field(default=256, ge=64, le=512, description="Memory limit in MB")
|
| 22 |
+
|
| 23 |
+
@field_validator('code')
|
| 24 |
+
@classmethod
|
| 25 |
+
def validate_code(cls, v: str) -> str:
|
| 26 |
+
if not v.strip():
|
| 27 |
+
raise ValueError("Code cannot be empty or whitespace only")
|
| 28 |
+
return v
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class ExecutionResponse(BaseModel):
|
| 32 |
+
"""Response model for code execution"""
|
| 33 |
+
stdout: str = Field(default="", description="Standard output")
|
| 34 |
+
stderr: str = Field(default="", description="Standard error")
|
| 35 |
+
exit_code: int = Field(..., description="Exit code of the process")
|
| 36 |
+
execution_time: float = Field(..., description="Execution time in seconds")
|
| 37 |
+
error: Optional[str] = Field(default=None, description="Error message if execution failed")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class SandboxConfig(BaseModel):
|
| 41 |
+
"""Configuration for sandbox execution"""
|
| 42 |
+
max_execution_time: int = Field(default=30, description="Maximum execution time in seconds")
|
| 43 |
+
max_memory_mb: int = Field(default=512, description="Maximum memory in MB")
|
| 44 |
+
enable_network: bool = Field(default=False, description="Enable network access in sandbox")
|
| 45 |
+
read_only_root: bool = Field(default=True, description="Make root filesystem read-only")
|
| 46 |
+
max_output_size: int = Field(default=1048576, description="Maximum output size in bytes (1MB)")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class LanguageInfo(BaseModel):
|
| 50 |
+
"""Information about a supported language"""
|
| 51 |
+
name: str
|
| 52 |
+
version: str
|
| 53 |
+
image: str
|
| 54 |
+
extensions: list[str]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class SessionStatus(str, Enum):
|
| 58 |
+
"""Session lifecycle status"""
|
| 59 |
+
CREATING = "creating"
|
| 60 |
+
READY = "ready"
|
| 61 |
+
BUSY = "busy"
|
| 62 |
+
STOPPING = "stopping"
|
| 63 |
+
ERROR = "error"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class CreateSessionRequest(BaseModel):
|
| 67 |
+
"""Request to create a new persistent session"""
|
| 68 |
+
metadata: Dict[str, Any] = Field(default_factory=dict, description="User-defined metadata")
|
| 69 |
+
timeout_minutes: int = Field(default=30, ge=5, le=120, description="Session idle timeout in minutes")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class SessionResponse(BaseModel):
|
| 73 |
+
"""Response with session information"""
|
| 74 |
+
session_id: str
|
| 75 |
+
container_id: str
|
| 76 |
+
volume_name: str
|
| 77 |
+
status: SessionStatus
|
| 78 |
+
created_at: datetime
|
| 79 |
+
last_activity: datetime
|
| 80 |
+
timeout_minutes: int
|
| 81 |
+
metadata: Dict[str, Any]
|
| 82 |
+
files_count: Optional[int] = None
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class FileInfo(BaseModel):
|
| 86 |
+
"""Information about a file in session"""
|
| 87 |
+
filename: str
|
| 88 |
+
path: str
|
| 89 |
+
size: int
|
| 90 |
+
modified_at: datetime
|
| 91 |
+
mime_type: str
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class FileUploadResponse(BaseModel):
|
| 95 |
+
"""Response after file upload"""
|
| 96 |
+
filename: str
|
| 97 |
+
path: str
|
| 98 |
+
size: int
|
| 99 |
+
message: str = "File uploaded successfully"
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class ExecuteInSessionRequest(BaseModel):
|
| 103 |
+
"""Request to execute code in an existing session"""
|
| 104 |
+
code: str = Field(..., min_length=1, max_length=50000)
|
| 105 |
+
language: Language
|
| 106 |
+
working_dir: str = Field(default="/workspace", description="Working directory for execution")
|
| 107 |
+
timeout: int = Field(default=30, ge=1, le=120)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class ExecuteFileRequest(BaseModel):
|
| 111 |
+
"""Request to execute an uploaded file"""
|
| 112 |
+
filepath: str = Field(..., description="Path to file in session workspace")
|
| 113 |
+
language: Language
|
| 114 |
+
args: list[str] = Field(default_factory=list, description="Command-line arguments")
|
| 115 |
+
timeout: int = Field(default=30, ge=1, le=120)
|
sandbox/session_manager.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import docker
|
| 2 |
+
from docker.errors import DockerException, NotFound, APIError
|
| 3 |
+
import logging
|
| 4 |
+
import uuid
|
| 5 |
+
from datetime import datetime, timedelta
|
| 6 |
+
from typing import Dict, Optional, List
|
| 7 |
+
import threading
|
| 8 |
+
import time
|
| 9 |
+
|
| 10 |
+
from sandbox.models import (
|
| 11 |
+
SessionResponse, SessionStatus, CreateSessionRequest,
|
| 12 |
+
SandboxConfig
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class SessionManager:
|
| 19 |
+
"""
|
| 20 |
+
Manages persistent VM-like container sessions.
|
| 21 |
+
|
| 22 |
+
Each session is a long-running Docker container with:
|
| 23 |
+
- Persistent volume for file storage
|
| 24 |
+
- Multi-language development environment
|
| 25 |
+
- Dedicated workspace directory
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
def __init__(self, config: Optional[SandboxConfig] = None):
|
| 29 |
+
self.config = config or SandboxConfig()
|
| 30 |
+
try:
|
| 31 |
+
self.client = docker.from_env()
|
| 32 |
+
self.client.ping()
|
| 33 |
+
logger.info("Docker client initialized for session management")
|
| 34 |
+
except DockerException as e:
|
| 35 |
+
logger.error(f"Failed to initialize Docker client: {e}")
|
| 36 |
+
raise RuntimeError("Docker is not available") from e
|
| 37 |
+
|
| 38 |
+
# In-memory session registry (use Redis for production)
|
| 39 |
+
self.sessions: Dict[str, SessionResponse] = {}
|
| 40 |
+
self._lock = threading.Lock()
|
| 41 |
+
|
| 42 |
+
# Start cleanup thread
|
| 43 |
+
self._cleanup_thread = threading.Thread(target=self._cleanup_loop, daemon=True)
|
| 44 |
+
self._cleanup_thread.start()
|
| 45 |
+
logger.info("Session cleanup thread started")
|
| 46 |
+
|
| 47 |
+
def create_session(self, request: CreateSessionRequest) -> SessionResponse:
|
| 48 |
+
"""
|
| 49 |
+
Create a new persistent session.
|
| 50 |
+
|
| 51 |
+
Args:
|
| 52 |
+
request: Session creation request
|
| 53 |
+
|
| 54 |
+
Returns:
|
| 55 |
+
SessionResponse with session details
|
| 56 |
+
"""
|
| 57 |
+
session_id = str(uuid.uuid4())
|
| 58 |
+
volume_name = f"sandbox-session-{session_id}"
|
| 59 |
+
|
| 60 |
+
try:
|
| 61 |
+
# Create dedicated volume
|
| 62 |
+
logger.info(f"Creating volume {volume_name}")
|
| 63 |
+
volume = self.client.volumes.create(
|
| 64 |
+
name=volume_name,
|
| 65 |
+
driver='local',
|
| 66 |
+
labels={'session_id': session_id}
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
# Create long-running container
|
| 70 |
+
logger.info(f"Creating session container for {session_id}")
|
| 71 |
+
container = self.client.containers.create(
|
| 72 |
+
image='sandbox-devenv:latest', # Our multi-language image
|
| 73 |
+
name=f"sandbox-session-{session_id}",
|
| 74 |
+
detach=True,
|
| 75 |
+
|
| 76 |
+
# Mount volume to workspace
|
| 77 |
+
volumes={
|
| 78 |
+
volume_name: {'bind': '/workspace', 'mode': 'rw'}
|
| 79 |
+
},
|
| 80 |
+
|
| 81 |
+
# Keep container running
|
| 82 |
+
command='tail -f /dev/null',
|
| 83 |
+
|
| 84 |
+
# Resource limits
|
| 85 |
+
mem_limit=f"{self.config.max_memory_mb}m",
|
| 86 |
+
memswap_limit=f"{self.config.max_memory_mb}m",
|
| 87 |
+
cpu_quota=100000, # 1 CPU core
|
| 88 |
+
cpu_period=100000,
|
| 89 |
+
|
| 90 |
+
# Network isolation (can be disabled for package installation)
|
| 91 |
+
network_mode="bridge" if self.config.enable_network else "none",
|
| 92 |
+
|
| 93 |
+
# Security
|
| 94 |
+
read_only=False, # Need write access for development
|
| 95 |
+
security_opt=["no-new-privileges"],
|
| 96 |
+
|
| 97 |
+
# Working directory
|
| 98 |
+
working_dir='/workspace',
|
| 99 |
+
|
| 100 |
+
# Labels for tracking
|
| 101 |
+
labels={
|
| 102 |
+
'session_id': session_id,
|
| 103 |
+
'managed_by': 'sandbox-api'
|
| 104 |
+
}
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
# Start the container
|
| 108 |
+
container.start()
|
| 109 |
+
logger.info(f"Started container {container.id[:12]} for session {session_id}")
|
| 110 |
+
|
| 111 |
+
# Create session object
|
| 112 |
+
now = datetime.utcnow()
|
| 113 |
+
session = SessionResponse(
|
| 114 |
+
session_id=session_id,
|
| 115 |
+
container_id=container.id,
|
| 116 |
+
volume_name=volume_name,
|
| 117 |
+
status=SessionStatus.READY,
|
| 118 |
+
created_at=now,
|
| 119 |
+
last_activity=now,
|
| 120 |
+
timeout_minutes=request.timeout_minutes,
|
| 121 |
+
metadata=request.metadata,
|
| 122 |
+
files_count=0
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
# Store in registry
|
| 126 |
+
with self._lock:
|
| 127 |
+
self.sessions[session_id] = session
|
| 128 |
+
|
| 129 |
+
logger.info(f"Session {session_id} created successfully")
|
| 130 |
+
return session
|
| 131 |
+
|
| 132 |
+
except Exception as e:
|
| 133 |
+
logger.error(f"Failed to create session: {e}", exc_info=True)
|
| 134 |
+
# Cleanup on failure
|
| 135 |
+
try:
|
| 136 |
+
if volume_name:
|
| 137 |
+
vol = self.client.volumes.get(volume_name)
|
| 138 |
+
vol.remove(force=True)
|
| 139 |
+
except:
|
| 140 |
+
pass
|
| 141 |
+
raise RuntimeError(f"Failed to create session: {str(e)}")
|
| 142 |
+
|
| 143 |
+
def get_session(self, session_id: str) -> Optional[SessionResponse]:
|
| 144 |
+
"""Get session by ID"""
|
| 145 |
+
with self._lock:
|
| 146 |
+
session = self.sessions.get(session_id)
|
| 147 |
+
if session:
|
| 148 |
+
# Update status from container
|
| 149 |
+
try:
|
| 150 |
+
container = self.client.containers.get(session.container_id)
|
| 151 |
+
if container.status == 'running':
|
| 152 |
+
session.status = SessionStatus.READY
|
| 153 |
+
else:
|
| 154 |
+
session.status = SessionStatus.ERROR
|
| 155 |
+
except:
|
| 156 |
+
session.status = SessionStatus.ERROR
|
| 157 |
+
return session
|
| 158 |
+
|
| 159 |
+
def list_sessions(self) -> List[SessionResponse]:
|
| 160 |
+
"""List all active sessions"""
|
| 161 |
+
with self._lock:
|
| 162 |
+
return list(self.sessions.values())
|
| 163 |
+
|
| 164 |
+
def update_activity(self, session_id: str):
|
| 165 |
+
"""Update last activity timestamp for a session"""
|
| 166 |
+
with self._lock:
|
| 167 |
+
if session_id in self.sessions:
|
| 168 |
+
self.sessions[session_id].last_activity = datetime.utcnow()
|
| 169 |
+
|
| 170 |
+
def destroy_session(self, session_id: str) -> bool:
|
| 171 |
+
"""
|
| 172 |
+
Destroy a session and cleanup resources.
|
| 173 |
+
|
| 174 |
+
Args:
|
| 175 |
+
session_id: Session to destroy
|
| 176 |
+
|
| 177 |
+
Returns:
|
| 178 |
+
True if destroyed, False if not found
|
| 179 |
+
"""
|
| 180 |
+
with self._lock:
|
| 181 |
+
session = self.sessions.pop(session_id, None)
|
| 182 |
+
|
| 183 |
+
if not session:
|
| 184 |
+
logger.warning(f"Session {session_id} not found")
|
| 185 |
+
return False
|
| 186 |
+
|
| 187 |
+
try:
|
| 188 |
+
# Stop and remove container
|
| 189 |
+
try:
|
| 190 |
+
container = self.client.containers.get(session.container_id)
|
| 191 |
+
container.stop(timeout=5)
|
| 192 |
+
container.remove(force=True)
|
| 193 |
+
logger.info(f"Removed container {session.container_id[:12]}")
|
| 194 |
+
except NotFound:
|
| 195 |
+
logger.warning(f"Container {session.container_id[:12]} not found")
|
| 196 |
+
except Exception as e:
|
| 197 |
+
logger.error(f"Error removing container: {e}")
|
| 198 |
+
|
| 199 |
+
# Remove volume
|
| 200 |
+
try:
|
| 201 |
+
volume = self.client.volumes.get(session.volume_name)
|
| 202 |
+
volume.remove(force=True)
|
| 203 |
+
logger.info(f"Removed volume {session.volume_name}")
|
| 204 |
+
except NotFound:
|
| 205 |
+
logger.warning(f"Volume {session.volume_name} not found")
|
| 206 |
+
except Exception as e:
|
| 207 |
+
logger.error(f"Error removing volume: {e}")
|
| 208 |
+
|
| 209 |
+
logger.info(f"Session {session_id} destroyed successfully")
|
| 210 |
+
return True
|
| 211 |
+
|
| 212 |
+
except Exception as e:
|
| 213 |
+
logger.error(f"Error destroying session {session_id}: {e}", exc_info=True)
|
| 214 |
+
return False
|
| 215 |
+
|
| 216 |
+
def _cleanup_loop(self):
|
| 217 |
+
"""Background thread to cleanup idle sessions"""
|
| 218 |
+
while True:
|
| 219 |
+
try:
|
| 220 |
+
time.sleep(60) # Check every minute
|
| 221 |
+
self._cleanup_idle_sessions()
|
| 222 |
+
except Exception as e:
|
| 223 |
+
logger.error(f"Error in cleanup loop: {e}", exc_info=True)
|
| 224 |
+
|
| 225 |
+
def _cleanup_idle_sessions(self):
|
| 226 |
+
"""Cleanup sessions that have exceeded their timeout"""
|
| 227 |
+
now = datetime.utcnow()
|
| 228 |
+
sessions_to_destroy = []
|
| 229 |
+
|
| 230 |
+
with self._lock:
|
| 231 |
+
for session_id, session in self.sessions.items():
|
| 232 |
+
timeout = timedelta(minutes=session.timeout_minutes)
|
| 233 |
+
if (now - session.last_activity) > timeout:
|
| 234 |
+
sessions_to_destroy.append(session_id)
|
| 235 |
+
logger.info(f"Session {session_id} exceeded timeout, will cleanup")
|
| 236 |
+
|
| 237 |
+
# Destroy outside lock to avoid deadlock
|
| 238 |
+
for session_id in sessions_to_destroy:
|
| 239 |
+
self.destroy_session(session_id)
|
| 240 |
+
|
| 241 |
+
def shutdown(self):
|
| 242 |
+
"""Cleanup all sessions on shutdown"""
|
| 243 |
+
logger.info("Shutting down session manager, cleaning up all sessions")
|
| 244 |
+
session_ids = list(self.sessions.keys())
|
| 245 |
+
for session_id in session_ids:
|
| 246 |
+
self.destroy_session(session_id)
|
tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Test package
|
tests/test_api.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from fastapi.testclient import TestClient
|
| 3 |
+
from app import app
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@pytest.fixture
|
| 7 |
+
def client():
|
| 8 |
+
"""FastAPI test client fixture"""
|
| 9 |
+
return TestClient(app)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def test_root_endpoint(client):
|
| 13 |
+
"""Test root endpoint returns API info"""
|
| 14 |
+
response = client.get("/")
|
| 15 |
+
assert response.status_code == 200
|
| 16 |
+
data = response.json()
|
| 17 |
+
assert data["name"] == "Code Execution Sandbox API"
|
| 18 |
+
assert data["version"] == "1.0.0"
|
| 19 |
+
assert "supported_languages" in data
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_health_endpoint(client):
|
| 23 |
+
"""Test health check endpoint"""
|
| 24 |
+
response = client.get("/health")
|
| 25 |
+
# May fail if Docker is not available, but endpoint should exist
|
| 26 |
+
assert response.status_code in [200, 503]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_languages_endpoint(client):
|
| 30 |
+
"""Test languages listing endpoint"""
|
| 31 |
+
response = client.get("/languages")
|
| 32 |
+
assert response.status_code == 200
|
| 33 |
+
data = response.json()
|
| 34 |
+
assert "languages" in data
|
| 35 |
+
languages = data["languages"]
|
| 36 |
+
assert len(languages) == 3
|
| 37 |
+
|
| 38 |
+
# Check language names
|
| 39 |
+
lang_names = [lang["name"] for lang in languages]
|
| 40 |
+
assert "Python" in lang_names
|
| 41 |
+
assert "JavaScript" in lang_names
|
| 42 |
+
assert "Bash" in lang_names
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_execute_python_hello_world(client):
|
| 46 |
+
"""Test executing simple Python code"""
|
| 47 |
+
response = client.post("/execute", json={
|
| 48 |
+
"code": "print('Hello, World!')",
|
| 49 |
+
"language": "python"
|
| 50 |
+
})
|
| 51 |
+
|
| 52 |
+
# If Docker is not available, this may fail with 503
|
| 53 |
+
if response.status_code == 200:
|
| 54 |
+
data = response.json()
|
| 55 |
+
assert "Hello, World!" in data["stdout"]
|
| 56 |
+
assert data["exit_code"] == 0
|
| 57 |
+
assert data["error"] is None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_execute_python_math(client):
|
| 61 |
+
"""Test Python arithmetic"""
|
| 62 |
+
response = client.post("/execute", json={
|
| 63 |
+
"code": "result = 2 + 2\nprint(f'Result: {result}')",
|
| 64 |
+
"language": "python"
|
| 65 |
+
})
|
| 66 |
+
|
| 67 |
+
if response.status_code == 200:
|
| 68 |
+
data = response.json()
|
| 69 |
+
assert "Result: 4" in data["stdout"]
|
| 70 |
+
assert data["exit_code"] == 0
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_execute_javascript_hello_world(client):
|
| 74 |
+
"""Test executing simple JavaScript code"""
|
| 75 |
+
response = client.post("/execute", json={
|
| 76 |
+
"code": "console.log('Hello from Node.js!');",
|
| 77 |
+
"language": "javascript"
|
| 78 |
+
})
|
| 79 |
+
|
| 80 |
+
if response.status_code == 200:
|
| 81 |
+
data = response.json()
|
| 82 |
+
assert "Hello from Node.js!" in data["stdout"]
|
| 83 |
+
assert data["exit_code"] == 0
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_execute_bash_command(client):
|
| 87 |
+
"""Test executing Bash command"""
|
| 88 |
+
response = client.post("/execute", json={
|
| 89 |
+
"code": "echo 'Bash works!'",
|
| 90 |
+
"language": "bash"
|
| 91 |
+
})
|
| 92 |
+
|
| 93 |
+
if response.status_code == 200:
|
| 94 |
+
data = response.json()
|
| 95 |
+
assert "Bash works!" in data["stdout"]
|
| 96 |
+
assert data["exit_code"] == 0
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_execute_with_syntax_error(client):
|
| 100 |
+
"""Test executing code with syntax error"""
|
| 101 |
+
response = client.post("/execute", json={
|
| 102 |
+
"code": "print('missing closing quote)",
|
| 103 |
+
"language": "python"
|
| 104 |
+
})
|
| 105 |
+
|
| 106 |
+
if response.status_code == 200:
|
| 107 |
+
data = response.json()
|
| 108 |
+
assert data["exit_code"] != 0
|
| 109 |
+
# Should have error in stderr or error field
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def test_execute_with_timeout(client):
|
| 113 |
+
"""Test timeout enforcement"""
|
| 114 |
+
response = client.post("/execute", json={
|
| 115 |
+
"code": "import time\ntime.sleep(5)",
|
| 116 |
+
"language": "python",
|
| 117 |
+
"timeout": 2
|
| 118 |
+
})
|
| 119 |
+
|
| 120 |
+
if response.status_code == 200:
|
| 121 |
+
data = response.json()
|
| 122 |
+
# Should timeout
|
| 123 |
+
assert data["execution_time"] < 3
|
| 124 |
+
assert data["error"] is not None or data["exit_code"] == 124
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_invalid_language(client):
|
| 128 |
+
"""Test invalid language rejection"""
|
| 129 |
+
response = client.post("/execute", json={
|
| 130 |
+
"code": "print('test')",
|
| 131 |
+
"language": "rust" # Not supported
|
| 132 |
+
})
|
| 133 |
+
|
| 134 |
+
assert response.status_code == 422 # Validation error
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def test_empty_code(client):
|
| 138 |
+
"""Test empty code rejection"""
|
| 139 |
+
response = client.post("/execute", json={
|
| 140 |
+
"code": "",
|
| 141 |
+
"language": "python"
|
| 142 |
+
})
|
| 143 |
+
|
| 144 |
+
assert response.status_code == 422 # Validation error
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def test_timeout_out_of_range(client):
|
| 148 |
+
"""Test timeout validation"""
|
| 149 |
+
response = client.post("/execute", json={
|
| 150 |
+
"code": "print('test')",
|
| 151 |
+
"language": "python",
|
| 152 |
+
"timeout": 100 # Exceeds max of 30
|
| 153 |
+
})
|
| 154 |
+
|
| 155 |
+
assert response.status_code == 422 # Validation error
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def test_memory_limit_validation(client):
|
| 159 |
+
"""Test memory limit validation"""
|
| 160 |
+
response = client.post("/execute", json={
|
| 161 |
+
"code": "print('test')",
|
| 162 |
+
"language": "python",
|
| 163 |
+
"memory_limit": 1024 # Exceeds max of 512
|
| 164 |
+
})
|
| 165 |
+
|
| 166 |
+
assert response.status_code == 422 # Validation error
|
tests/test_executor.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from sandbox.executor import SandboxExecutor
|
| 3 |
+
from sandbox.models import ExecutionRequest, SandboxConfig, Language
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
# Skip all tests if Docker is not available
|
| 7 |
+
docker = pytest.importorskip("docker")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@pytest.fixture
|
| 11 |
+
def executor():
|
| 12 |
+
"""Sandbox executor fixture"""
|
| 13 |
+
try:
|
| 14 |
+
return SandboxExecutor(SandboxConfig())
|
| 15 |
+
except RuntimeError:
|
| 16 |
+
pytest.skip("Docker is not available")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_executor_initialization(executor):
|
| 20 |
+
"""Test executor can be initialized"""
|
| 21 |
+
assert executor is not None
|
| 22 |
+
assert executor.client is not None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_execute_python_simple(executor):
|
| 26 |
+
"""Test simple Python execution"""
|
| 27 |
+
request = ExecutionRequest(
|
| 28 |
+
code="print('Test')",
|
| 29 |
+
language=Language.PYTHON
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
response = executor.execute(request)
|
| 33 |
+
|
| 34 |
+
assert "Test" in response.stdout
|
| 35 |
+
assert response.exit_code == 0
|
| 36 |
+
assert response.error is None
|
| 37 |
+
assert response.execution_time > 0
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_execute_python_with_variables(executor):
|
| 41 |
+
"""Test Python with variable assignment"""
|
| 42 |
+
request = ExecutionRequest(
|
| 43 |
+
code="x = 10\ny = 20\nprint(x + y)",
|
| 44 |
+
language=Language.PYTHON
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
response = executor.execute(request)
|
| 48 |
+
|
| 49 |
+
assert "30" in response.stdout
|
| 50 |
+
assert response.exit_code == 0
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_execute_javascript_simple(executor):
|
| 54 |
+
"""Test simple JavaScript execution"""
|
| 55 |
+
request = ExecutionRequest(
|
| 56 |
+
code="console.log('JavaScript works!');",
|
| 57 |
+
language=Language.JAVASCRIPT
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
response = executor.execute(request)
|
| 61 |
+
|
| 62 |
+
assert "JavaScript works!" in response.stdout
|
| 63 |
+
assert response.exit_code == 0
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_execute_bash_simple(executor):
|
| 67 |
+
"""Test simple Bash execution"""
|
| 68 |
+
request = ExecutionRequest(
|
| 69 |
+
code="echo 'Bash test'",
|
| 70 |
+
language=Language.BASH
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
response = executor.execute(request)
|
| 74 |
+
|
| 75 |
+
assert "Bash test" in response.stdout
|
| 76 |
+
assert response.exit_code == 0
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_timeout_enforcement(executor):
|
| 80 |
+
"""Test that timeout is enforced"""
|
| 81 |
+
request = ExecutionRequest(
|
| 82 |
+
code="import time\nwhile True:\n time.sleep(1)",
|
| 83 |
+
language=Language.PYTHON,
|
| 84 |
+
timeout=2
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
response = executor.execute(request)
|
| 88 |
+
|
| 89 |
+
# Should timeout
|
| 90 |
+
assert response.execution_time < 3
|
| 91 |
+
assert response.exit_code == 124 or response.error is not None
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_syntax_error_handling(executor):
|
| 95 |
+
"""Test handling of code with syntax errors"""
|
| 96 |
+
request = ExecutionRequest(
|
| 97 |
+
code="print('missing quote)",
|
| 98 |
+
language=Language.PYTHON
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
response = executor.execute(request)
|
| 102 |
+
|
| 103 |
+
assert response.exit_code != 0
|
| 104 |
+
# Error should be captured in stderr or stdout
|
| 105 |
+
assert len(response.stdout + response.stderr) > 0
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def test_runtime_error_handling(executor):
|
| 109 |
+
"""Test handling of runtime errors"""
|
| 110 |
+
request = ExecutionRequest(
|
| 111 |
+
code="x = 1 / 0", # Division by zero
|
| 112 |
+
language=Language.PYTHON
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
response = executor.execute(request)
|
| 116 |
+
|
| 117 |
+
assert response.exit_code != 0
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def test_container_cleanup(executor):
|
| 121 |
+
"""Test that containers are cleaned up after execution"""
|
| 122 |
+
import docker
|
| 123 |
+
client = docker.from_env()
|
| 124 |
+
|
| 125 |
+
# Get initial container count
|
| 126 |
+
initial_containers = len(client.containers.list(all=True))
|
| 127 |
+
|
| 128 |
+
# Execute code
|
| 129 |
+
request = ExecutionRequest(
|
| 130 |
+
code="print('cleanup test')",
|
| 131 |
+
language=Language.PYTHON
|
| 132 |
+
)
|
| 133 |
+
executor.execute(request)
|
| 134 |
+
|
| 135 |
+
# Check container count after execution
|
| 136 |
+
final_containers = len(client.containers.list(all=True))
|
| 137 |
+
|
| 138 |
+
# Should be the same (container was cleaned up)
|
| 139 |
+
assert final_containers == initial_containers
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def test_memory_limit_config(executor):
|
| 143 |
+
"""Test that memory limit is applied"""
|
| 144 |
+
request = ExecutionRequest(
|
| 145 |
+
code="print('memory test')",
|
| 146 |
+
language=Language.PYTHON,
|
| 147 |
+
memory_limit=128
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
response = executor.execute(request)
|
| 151 |
+
|
| 152 |
+
# Should execute successfully with lower memory
|
| 153 |
+
assert response.exit_code == 0
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def test_output_truncation(executor):
|
| 157 |
+
"""Test that large output is truncated"""
|
| 158 |
+
# Generate large output
|
| 159 |
+
code = "for i in range(100000):\n print('x' * 100)"
|
| 160 |
+
|
| 161 |
+
request = ExecutionRequest(
|
| 162 |
+
code=code,
|
| 163 |
+
language=Language.PYTHON
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
response = executor.execute(request)
|
| 167 |
+
|
| 168 |
+
# Output should be truncated
|
| 169 |
+
assert "truncated" in response.stdout or len(response.stdout) <= executor.config.max_output_size + 100
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def test_multiple_executions(executor):
|
| 173 |
+
"""Test multiple consecutive executions"""
|
| 174 |
+
for i in range(5):
|
| 175 |
+
request = ExecutionRequest(
|
| 176 |
+
code=f"print('Execution {i}')",
|
| 177 |
+
language=Language.PYTHON
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
response = executor.execute(request)
|
| 181 |
+
|
| 182 |
+
assert f"Execution {i}" in response.stdout
|
| 183 |
+
assert response.exit_code == 0
|