diff --git a/README.md b/README.md index 358f613..7e1efbf 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,12 @@

- Your AI agent's computer. On YOUR machine. + E2B charges per second. You run the computer.

- ForgeVM lets any LLM spawn isolated microVMs, run code, manage files, and destroy everything when done.
- Self-hosted. Single binary. ~28ms to boot. No cloud required. + Self-hosted sandbox orchestration for AI agents. Single binary.
+ Firecracker microVMs (~28ms boot) or Docker containers (no KVM needed). Python + TypeScript SDKs.

@@ -17,226 +17,197 @@ CI Go 1.25+ MIT License - Linux

--- -## Why ForgeVM - -| | **ForgeVM** | **E2B** | -|--|-------------|---------| -| **Hosting** | Self-hosted (your machine) | Cloud only | -| **Isolation** | KVM microVM (hardware) | Firecracker microVM | -| **Boot time** | ~28ms (snapshot restore) | ~500ms | -| **Pricing** | Free forever | Pay per use | -| **Data privacy** | Data never leaves your machine | Data on their servers | -| **Setup** | One command | Sign up + API key | -| **Multi-user pools** | Built-in | No | -| **Python SDK** | Yes (sync + async) | Yes | -| **TypeScript SDK** | Yes | Yes | - -**Key advantages:** - -- **Your data stays on your machine.** No cloud API calls. No vendor lock-in. No usage billing. -- **~28ms boot** — snapshot/restore, not cold boot. Faster than every cloud alternative. -- **Drop-in E2B replacement** — same API shape, same SDKs, point at localhost instead of `api.e2b.dev`. -- **Real VM isolation** — KVM hardware virtualization, not containers. AI-generated code can't escape. - ---- - -## Install - -### One command (recommended) +## 30-second demo ```bash +# Install curl -fsSL https://raw.githubusercontent.com/DohaerisAI/forgevm/main/scripts/install.sh | bash -``` -Downloads pre-built binaries, installs Firecracker, downloads the kernel. No Go required. +# Start +forgevm serve -### Build from source +# Spawn → run code → destroy +SB=$(curl -s -X POST localhost:7423/api/v1/sandboxes \ + -H 'Content-Type: application/json' \ + -d '{"image":"python:3.12"}' | jq -r .id) -```bash -git clone https://github.com/DohaerisAI/forgevm && cd forgevm -./scripts/setup.sh -``` +curl -s -X POST localhost:7423/api/v1/sandboxes/$SB/exec \ + -d '{"command":"python3 -c \"print(2**32)\"}' | jq .stdout +# "4294967296\n" -Requires Go 1.25+, Docker, and KVM. Sets up XFS reflink for fastest possible snapshot restores. +curl -s -X DELETE localhost:7423/api/v1/sandboxes/$SB +``` --- -## Quick Start - -```bash -# 1. Install -curl -fsSL https://raw.githubusercontent.com/DohaerisAI/forgevm/main/scripts/install.sh | bash - -# 2. Start the server -forgevm serve - -# 3. Spawn a sandbox, run code, destroy it -SANDBOX=$(curl -s -X POST localhost:7423/api/v1/sandboxes \ - -H "Content-Type: application/json" \ - -d '{"image":"alpine:latest"}' | jq -r .id) - -curl -s -X POST localhost:7423/api/v1/sandboxes/$SANDBOX/exec \ - -H "Content-Type: application/json" \ - -d '{"command":"echo hello from ForgeVM"}' -# {"exit_code":0,"stdout":"hello from ForgeVM\n"} +## Why ForgeVM -curl -s -X DELETE localhost:7423/api/v1/sandboxes/$SANDBOX -``` +| | **ForgeVM (Firecracker)** | **ForgeVM (Docker)** | **E2B** | **Docker alone** | +|--|:---:|:---:|:---:|:---:| +| Hosting | Self-hosted | Self-hosted | Cloud only | Self-hosted | +| KVM required | Yes | **No** | No (managed) | No | +| Works on macOS/Windows | No | **Yes** | Yes | Yes | +| Works in CI | No | **Yes** | Yes | Yes | +| Isolation level | KVM (hardware) | cgroups + namespaces | Firecracker | cgroups + namespaces | +| Boot time | **~28ms** | <1s | ~500ms | ~1s | +| Pricing | **Free** | **Free** | $0.000075/sec | Free | +| Pool mode (N users / 1 VM) | **Yes** | **Yes** | No | No | +| Python + TypeScript SDKs | **Yes** | **Yes** | Yes | No | +| Data privacy | Your machine | Your machine | Their cloud | Your machine | +| REST API + WebSocket | **Yes** | **Yes** | Yes | No | +| E2B drop-in replacement | **Yes** | **Yes** | — | No | + +### The math + +Running 10 sandboxes, 8 hours/day, 30 days: + +| | Cost | +|--|--| +| **E2B** | 10 × 8h × 30d × $0.27/h ≈ **$648/month** | +| **ForgeVM pool mode** | 10 users → 2 containers → **$0** | --- -## Give Your LLM a Computer +## Two providers, one API -ForgeVM turns any LLM into a coding agent. Define `execute_code` and `write_file` as tools, and let the model call them — ForgeVM handles sandboxed execution. +ForgeVM ships two execution backends. Same REST API, same SDKs, same config format — swap with one line. -### OpenAI function calling +### Firecracker — production, hardware isolation -```python -import openai -import requests +Real KVM microVMs. Each sandbox gets its own kernel, rootfs, and network namespace. AI-generated code cannot escape to the host — even a kernel exploit only affects that sandbox's guest kernel. -FORGEVM = "http://localhost:7423/api/v1" +**First spawn:** cold boot ~1s + snapshot creation +**Every spawn after:** restore from snapshot **~28ms** -# Spawn a sandbox for this conversation -sb = requests.post(f"{FORGEVM}/sandboxes", json={"image": "python:3.12"}).json() -sandbox_id = sb["id"] - -tools = [ - { - "type": "function", - "function": { - "name": "execute_code", - "description": "Execute a shell command in a sandboxed VM", - "parameters": { - "type": "object", - "properties": {"command": {"type": "string"}}, - "required": ["command"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "write_file", - "description": "Write a file in the sandbox", - "parameters": { - "type": "object", - "properties": { - "path": {"type": "string"}, - "content": {"type": "string"}, - }, - "required": ["path", "content"], - }, - }, - }, -] +```yaml +# forgevm.yaml +providers: + default: "firecracker" + firecracker: + enabled: true +``` +Requires: Linux + KVM. Run `curl ... | bash` on any bare-metal or nested-virt cloud VM. -def handle_tool_call(name, args): - if name == "execute_code": - r = requests.post(f"{FORGEVM}/sandboxes/{sandbox_id}/exec", json=args) - return r.json() - elif name == "write_file": - r = requests.post( - f"{FORGEVM}/sandboxes/{sandbox_id}/files", - json={"path": args["path"], "content": args["content"]}, - ) - return r.json() +### Docker — everywhere, no KVM +OCI containers as sandboxes. Works on macOS, Windows (Docker Desktop), Linux, and every CI provider. Same pool mode, same file API, same SDKs. -client = openai.OpenAI() -messages = [{"role": "user", "content": "Write a Python script that prints the first 10 primes, then run it"}] +```yaml +# forgevm.yaml +providers: + default: "docker" + docker: + enabled: true + default_image: "python:3.12-slim" + network_mode: "none" # air-gapped by default + read_only_rootfs: false + memory: "512m" + cpus: "1" + dropped_caps: ["ALL"] # drop all capabilities +``` -while True: - response = client.chat.completions.create(model="gpt-4o", messages=messages, tools=tools) - msg = response.choices[0].message - messages.append(msg) - - if msg.tool_calls: - for tc in msg.tool_calls: - import json - result = handle_tool_call(tc.function.name, json.loads(tc.function.arguments)) - messages.append({"role": "tool", "tool_call_id": tc.id, "content": json.dumps(result)}) - else: - print(msg.content) - break +Isolation is cgroup + namespace based (same as Docker), not hypervisor-level. Use for development, CI, and workloads where full VM isolation isn't required. -# Clean up -requests.delete(f"{FORGEVM}/sandboxes/{sandbox_id}") -``` +--- + +## Give your LLM a computer -### Claude tool_use +Define one tool. Point it at ForgeVM. Done. ```python -import anthropic -import requests -import json +import anthropic, requests FORGEVM = "http://localhost:7423/api/v1" sb = requests.post(f"{FORGEVM}/sandboxes", json={"image": "python:3.12"}).json() -sandbox_id = sb["id"] - -tools = [ - { - "name": "execute_code", - "description": "Execute a shell command in a sandboxed VM", - "input_schema": { - "type": "object", - "properties": {"command": {"type": "string"}}, - "required": ["command"], - }, - }, - { - "name": "write_file", - "description": "Write a file in the sandbox", - "input_schema": { - "type": "object", - "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, - "required": ["path", "content"], + +tools = [{ + "name": "computer", + "description": "Execute Python code or shell commands in a sandboxed environment.", + "input_schema": { + "type": "object", + "properties": { + "code": {"type": "string", "description": "Code to run (Python or shell)"}, + "lang": {"type": "string", "enum": ["python", "shell"], "default": "python"} }, - }, -] + "required": ["code"] + } +}] + +def run_tool(input): + cmd = f"python3 -c {input['code']!r}" if input.get("lang") != "shell" else input["code"] + r = requests.post(f"{FORGEVM}/sandboxes/{sb['id']}/exec", json={"command": cmd}) + return r.json() client = anthropic.Anthropic() -messages = [{"role": "user", "content": "Write a Python script that calculates fibonacci numbers, then run it"}] +messages = [{"role": "user", "content": "Analyze the first 1000 prime numbers and plot their distribution"}] while True: - response = client.messages.create(model="claude-sonnet-4-20250514", max_tokens=4096, tools=tools, messages=messages) - - # Collect assistant response + response = client.messages.create(model="claude-opus-4-5", max_tokens=4096, tools=tools, messages=messages) messages.append({"role": "assistant", "content": response.content}) if response.stop_reason == "tool_use": - tool_results = [] + results = [] for block in response.content: if block.type == "tool_use": - if block.name == "execute_code": - r = requests.post(f"{FORGEVM}/sandboxes/{sandbox_id}/exec", json=block.input) - result = r.json() - elif block.name == "write_file": - r = requests.post(f"{FORGEVM}/sandboxes/{sandbox_id}/files", json={"path": block.input["path"], "content": block.input["content"]}) - result = r.json() - tool_results.append({"type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result)}) - messages.append({"role": "user", "content": tool_results}) + output = run_tool(block.input) + results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)}) + messages.append({"role": "user", "content": results}) else: - for block in response.content: - if hasattr(block, "text"): - print(block.text) + print(next(b.text for b in response.content if hasattr(b, "text"))) break -requests.delete(f"{FORGEVM}/sandboxes/{sandbox_id}") +requests.delete(f"{FORGEVM}/sandboxes/{sb['id']}") +``` + +Works with OpenAI, Anthropic, or any model that supports tool/function calling. Each conversation gets its own sandbox. Destroy it when the conversation ends. + +--- + +## Multi-user pool mode + +One VM. Many users. Each isolated in their own workspace directory. + +``` + ┌─────────────────────────────┐ + User A ──────────► /workspace/sb-a1b2c3d4/ │ + User B ──────────► /workspace/sb-b9e8f7c6/ │ VM-1 (2GB RAM, 2 vCPU) + User C ──────────► /workspace/sb-c3d4e5f6/ │ + └─────────────────────────────┘ + ┌─────────────────────────────┐ + User D ──────────► /workspace/sb-d1e2f3a4/ │ + User E ──────────► /workspace/sb-e5f6a7b8/ │ VM-2 (2GB RAM, 2 vCPU) + └─────────────────────────────┘ + + 10 users. 2 VMs. Directory-level isolation. Files are invisible across users. +``` + +```yaml +# forgevm.yaml +pool: + enabled: true + max_vms: 10 + max_users_per_vm: 5 + image: "python:3.12" + memory_mb: 2048 + vcpus: 2 + overflow: "reject" # or "queue" +``` + +```bash +curl -s localhost:7423/api/v1/pool/status | jq +# { "total_vms": 2, "active_users": 5, "available_slots": 5 } ``` -**That's it.** Any LLM that supports tool calling can now write and execute code safely on your machine. +Works with both Firecracker and Docker providers. With Firecracker, VMs are pre-warmed from snapshots — users get assigned in ~28ms. With Docker, containers spin up in under a second. --- -## Use with Python +## Python SDK ```bash pip install forgevm @@ -247,63 +218,59 @@ from forgevm import Client client = Client("http://localhost:7423") -# Spawn and execute -sandbox = client.spawn(image="alpine:latest") -result = sandbox.exec("echo hello world") -print(result.stdout) # "hello world\n" - -# File operations -sandbox.write_file("/app/main.py", 'print("built with ForgeVM")') -result = sandbox.exec("python3 /app/main.py") -print(result.stdout) # "built with ForgeVM\n" - -# Extended file operations -content = sandbox.read_file("/etc/hostname") -files = sandbox.list_files("/app") -sandbox.move_file("/app/main.py", "/app/app.py") -sandbox.chmod_file("/app/app.py", "755") -info = sandbox.stat_file("/app/app.py") -matches = sandbox.glob_files("/app/*.py") -sandbox.delete_file("/app/app.py") - -# Extend TTL -sandbox.extend_ttl("30m") - -# Stream output in real-time -for chunk in sandbox.exec_stream("ping -c 3 localhost"): - print(chunk.data, end="") - -# Pool status (multi-user mode) -status = client.pool_status() -print(status) - -# Clean up -sandbox.destroy() +# Lifecycle +sb = client.spawn(image="python:3.12", memory_mb=1024, vcpus=1) +result = sb.exec("python3 -c 'print(\"hello\")'") +print(result.stdout) # hello + +# Files +sb.write_file("/app/main.py", 'print("built with ForgeVM")') +sb.exec("python3 /app/main.py") +content = sb.read_file("/app/main.py") +files = sb.list_files("/app") +sb.move_file("/app/main.py", "/app/app.py") +sb.chmod_file("/app/app.py", "755") +info = sb.stat_file("/app/app.py") +matches = sb.glob_files("/app/*.py") +sb.delete_file("/app/app.py") + +# Streaming +for chunk in sb.exec_stream("for i in $(seq 5); do echo line $i; sleep 0.1; done"): + print(chunk.data, end="", flush=True) + +# TTL + cleanup +sb.extend_ttl("30m") +sb.destroy() ``` -**Context manager** — auto-destroys on exit: +**Context manager** — auto-destroys: ```python with client.spawn(image="python:3.12") as sb: - sb.exec("pip install requests") - sb.exec("python3 -c 'import requests; print(requests.get(\"https://httpbin.org/ip\").text)'") + sb.exec("pip install httpx -q") + result = sb.exec("python3 -c 'import httpx; print(httpx.get(\"https://httpbin.org/ip\").text)'") + print(result.stdout) # sandbox destroyed automatically ``` -**Async support:** +**Async:** ```python from forgevm import AsyncClient +import asyncio -async with AsyncClient("http://localhost:7423") as client: - sandbox = await client.spawn(image="alpine:latest") - result = await sandbox.exec("whoami") - await sandbox.destroy() +async def main(): + async with AsyncClient("http://localhost:7423") as client: + sb = await client.spawn(image="python:3.12") + result = await sb.exec("python3 -c 'print(42)'") + await sb.destroy() + +asyncio.run(main()) ``` --- -## Use with TypeScript +## TypeScript SDK ```bash npm install forgevm @@ -313,360 +280,236 @@ npm install forgevm import { Client } from "forgevm"; const client = new Client("http://localhost:7423"); - -// Spawn and execute -const sandbox = await client.spawn({ image: "alpine:latest" }); -const result = await sandbox.exec("echo hello world"); -console.log(result.stdout); // "hello world\n" - -// File operations -await sandbox.writeFile("/app/index.js", 'console.log("hi")'); -await sandbox.exec("node /app/index.js"); - -// Extended file operations -const content = await sandbox.readFile("/etc/hostname"); -const files = await sandbox.listFiles("/app"); -await sandbox.moveFile("/app/index.js", "/app/app.js"); -await sandbox.chmodFile("/app/app.js", "755"); -const info = await sandbox.statFile("/app/app.js"); -const matches = await sandbox.globFiles("/app/*.js"); -await sandbox.deleteFile("/app/app.js"); - -// Extend TTL -await sandbox.extendTtl("30m"); - -// Stream output -for await (const chunk of sandbox.execStream("ping -c 3 localhost")) { +const sb = await client.spawn({ image: "node:22" }); + +const result = await sb.exec("node -e 'console.log(process.version)'"); +console.log(result.stdout); + +// Files +await sb.writeFile("/app/index.js", 'console.log("hello from ForgeVM")'); +await sb.exec("node /app/index.js"); +const content = await sb.readFile("/app/index.js"); +const files = await sb.listFiles("/app"); +await sb.moveFile("/app/index.js", "/app/app.js"); +await sb.chmodFile("/app/app.js", "755"); +const info = await sb.statFile("/app/app.js"); +const matches = await sb.globFiles("/app/*.js"); +await sb.deleteFile("/app/app.js"); + +// Streaming +for await (const chunk of sb.execStream("ping -c 3 localhost")) { process.stdout.write(chunk.data); } -await sandbox.destroy(); +await sb.extendTtl("30m"); +await sb.destroy(); ``` --- -## Multi-User Pools +## Install -Run a shared pool of VMs that multiple users (or agents) share. Each user gets an isolated session within a pre-warmed VM. - -```yaml -# forgevm.yaml -pool: - enabled: true - max_vms: 10 - max_users_per_vm: 5 - image: "python:3.12" - memory_mb: 2048 - vcpus: 2 - overflow: "reject" # or "queue" -``` +### Pre-built binaries (recommended) ```bash -# Check pool status -curl -s localhost:7423/api/v1/pool/status | jq -``` - -```json -{ - "total_vms": 10, - "active_users": 23, - "available_slots": 27, - "vms": [ - {"id": "sb-a1b2c3d4", "users": 3, "max_users": 5, "memory_mb": 2048} - ] -} +curl -fsSL https://raw.githubusercontent.com/DohaerisAI/forgevm/main/scripts/install.sh | bash ``` -VMs are pre-warmed from snapshots (~28ms). When a user connects, they get assigned to a VM with available capacity. When `overflow` is `"reject"`, new requests fail with 503 if all slots are full. With `"queue"`, they wait for a slot. +Downloads `forgevm` + `forgevm-agent`, installs Firecracker, downloads the Linux kernel. No Go required. KVM access is needed for Firecracker — if your machine doesn't have KVM, use the Docker provider instead. ---- - -## CLI +### Build from source ```bash -forgevm serve # start server -forgevm spawn --image alpine:latest --ttl 1h # spawn a sandbox -forgevm list # list sandboxes -forgevm exec sb-a1b2c3d4 -- echo hello world # run a command -forgevm kill sb-a1b2c3d4 # destroy -forgevm build-image python:3.12 # pre-build rootfs from Docker image -forgevm tui # interactive dashboard -forgevm version # print version +git clone https://github.com/DohaerisAI/forgevm && cd forgevm +./scripts/setup.sh ``` ---- - -## REST API - -Base: `http://localhost:7423/api/v1` - -### Sandboxes - -| Method | Endpoint | Description | -|--------|----------|-------------| -| `POST` | `/sandboxes` | Create a sandbox | -| `GET` | `/sandboxes` | List all sandboxes | -| `DELETE` | `/sandboxes` | Prune expired sandboxes | -| `GET` | `/sandboxes/:id` | Get sandbox details | -| `DELETE` | `/sandboxes/:id` | Destroy a sandbox | -| `POST` | `/sandboxes/:id/extend` | Extend TTL | -| `POST` | `/sandboxes/:id/exec` | Execute a command | -| `GET` | `/sandboxes/:id/exec/ws` | Execute via WebSocket | -| `POST` | `/sandboxes/:id/files` | Write a file | -| `GET` | `/sandboxes/:id/files` | Read a file | -| `DELETE` | `/sandboxes/:id/files` | Delete a file | -| `GET` | `/sandboxes/:id/files/list` | List directory | -| `POST` | `/sandboxes/:id/files/move` | Move/rename file | -| `POST` | `/sandboxes/:id/files/chmod` | Change permissions | -| `GET` | `/sandboxes/:id/files/stat` | File stat info | -| `GET` | `/sandboxes/:id/files/glob` | Glob pattern match | -| `GET` | `/sandboxes/:id/logs` | Console logs | - -### Templates - -| Method | Endpoint | Description | -|--------|----------|-------------| -| `POST` | `/templates` | Create a template | -| `GET` | `/templates` | List templates | -| `GET` | `/templates/:name` | Get template | -| `PUT` | `/templates/:name` | Update template | -| `DELETE` | `/templates/:name` | Delete template | -| `POST` | `/templates/:name/spawn` | Spawn from template | - -### Providers - -| Method | Endpoint | Description | -|--------|----------|-------------| -| `GET` | `/providers` | List providers | -| `POST` | `/providers/test` | Test provider health | -| `GET` | `/providers/:name` | Provider details | - -### Snapshots - -| Method | Endpoint | Description | -|--------|----------|-------------| -| `GET` | `/snapshots` | List snapshots | +Requires Go 1.25+, Docker (for image builds), and KVM. Sets up XFS reflink for the fastest possible snapshot restores. -### Environments - -| Method | Endpoint | Description | -|--------|----------|-------------| -| `POST` | `/environments/specs` | Create environment spec | -| `GET` | `/environments/specs` | List specs | -| `GET` | `/environments/specs/:id` | Get spec | -| `GET` | `/environments/specs/:id/suggestions` | Package suggestions | -| `POST` | `/environments/builds` | Start build | -| `GET` | `/environments/builds` | List builds | -| `GET` | `/environments/builds/:id` | Get build | -| `POST` | `/environments/builds/:id/cancel` | Cancel build | -| `GET` | `/environments/builds/:id/spawn-config` | Spawn config | -| `POST` | `/environments/registry-connections` | Save registry credentials | -| `GET` | `/environments/registry-connections` | List connections | -| `DELETE` | `/environments/registry-connections/:id` | Delete connection | - -### System - -| Method | Endpoint | Description | -|--------|----------|-------------| -| `GET` | `/health` | Health check | -| `GET` | `/metrics` | Runtime metrics | -| `GET` | `/events` | SSE event stream | -| `GET` | `/pool/status` | VM pool status | - ---- +### Docker only (no KVM, no Firecracker) -## Providers - -ForgeVM uses a **provider interface** — swap backends without changing your code. - -### Mock (default — zero setup) - -Runs commands with `os/exec` in temp directories on the host. No VMs. Ships enabled by default for development and testing. Supports custom Docker images via `build-image`. - -### Firecracker (production) - -Real Firecracker microVMs with KVM hardware isolation. Each sandbox gets its own kernel, rootfs, and network namespace. +If you just want Docker provider (macOS, Windows, CI): ```bash -# Full setup (builds from source + XFS reflink) -./scripts/setup.sh - -# Or with pre-built binaries -curl -fsSL https://raw.githubusercontent.com/DohaerisAI/forgevm/main/scripts/install.sh | bash -sudo chmod 666 /dev/kvm - -# Enable in config -cat > forgevm.yaml < `~/.forgevm/config.yaml` > env vars (`FORGEVM_SERVER_PORT=8080`) +Config priority: `./forgevm.yaml` → `~/.forgevm/config.yaml` → env vars (`FORGEVM_SERVER_PORT=8080`) --- -## Web Dashboard +## REST API -Built-in React dashboard with sandbox management, live terminal, file browser, and log viewer. +`http://localhost:7423/api/v1` -```bash -make web # build frontend -forgevm serve # open http://localhost:7423 -``` +### Sandboxes ---- +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/sandboxes` | Spawn a sandbox | +| `GET` | `/sandboxes` | List all sandboxes | +| `DELETE` | `/sandboxes` | Prune expired | +| `GET` | `/sandboxes/:id` | Get sandbox | +| `DELETE` | `/sandboxes/:id` | Destroy | +| `POST` | `/sandboxes/:id/extend` | Extend TTL | +| `POST` | `/sandboxes/:id/exec` | Execute command | +| `GET` | `/sandboxes/:id/exec/ws` | Execute via WebSocket | +| `POST` | `/sandboxes/:id/files` | Write file | +| `GET` | `/sandboxes/:id/files` | Read file | +| `DELETE` | `/sandboxes/:id/files` | Delete file | +| `GET` | `/sandboxes/:id/files/list` | List directory | +| `POST` | `/sandboxes/:id/files/move` | Move / rename | +| `POST` | `/sandboxes/:id/files/chmod` | Change permissions | +| `GET` | `/sandboxes/:id/files/stat` | File info | +| `GET` | `/sandboxes/:id/files/glob` | Glob pattern | +| `GET` | `/sandboxes/:id/logs` | Console output | -## Security +### Templates · Providers · Environments · System -### Why VMs > Containers for AI Agents +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST/GET` | `/templates` | Sandbox templates | +| `POST` | `/templates/:name/spawn` | Spawn from template | +| `GET` | `/providers` | List registered providers | +| `POST` | `/providers/test` | Health-check a provider | +| `POST/GET` | `/environments/specs` | Environment specs | +| `POST/GET` | `/environments/builds` | Image builds | +| `POST` | `/environments/registry-connections` | Registry credentials | +| `GET` | `/health` | Health check | +| `GET` | `/metrics` | Runtime metrics | +| `GET` | `/events` | SSE event stream | +| `GET` | `/pool/status` | Pool status | -AI agents generate and run **untrusted code**. Containers share the host kernel — a single kernel exploit means host compromise. ForgeVM uses Firecracker microVMs: each sandbox gets its own kernel with KVM hardware isolation. Even if AI-generated code exploits a kernel vulnerability, it only affects that sandbox's kernel, not the host. +--- -- **KVM isolation** — each sandbox = its own kernel, rootfs, network -- **No shared kernel** — guest exploits can't reach the host -- **Ephemeral rootfs** — destroyed on teardown, nothing persists -- **vsock only** — host/guest communicate over virtio-vsock, zero network exposure -- **API key auth** — optional, on all endpoints -- **Auto-expiry** — sandboxes destroyed after TTL +## Architecture -See [SECURITY.md](SECURITY.md) for vulnerability reporting. +![ForgeVM Architecture](assets/forgevm_architecture_diagram.svg) --- -## vs E2B +## Security -| | **ForgeVM** | **E2B** | -|--|-------------|---------| -| Cost | Free forever | $0.000075/s per sandbox | -| Data location | Your machine | Their cloud | -| Latency | ~28ms (local) | ~500ms + network RTT | -| Internet required | No | Yes | -| Max sandboxes | Your hardware limit | Plan-dependent | -| Custom images | Any Docker image | E2B templates only | -| Open source | MIT | Partial | +### Firecracker: hardware-level isolation ---- +Each sandbox runs its own Linux kernel inside a KVM virtual machine. AI-generated code that exploits a kernel vulnerability only compromises that sandbox's kernel — the host is untouched. The guest agent communicates with the host over `virtio-vsock` only. No network exposure, no shared kernel. -## Architecture +- Each sandbox = dedicated kernel + ephemeral rootfs +- vsock only — zero host network exposure +- Auto-TTL — sandbox destroyed after timeout +- Optional API key authentication +### Docker: defense-in-depth + +Docker sandboxes use `cap_drop: ALL`, `network_mode: none`, `read_only_rootfs`, PID limits, and seccomp profiles by default. Not hardware-isolated like Firecracker, but appropriate for trusted workloads, development, and CI environments. + +Switching runtimes is one config line: + +```yaml +docker: + runtime: "runsc" # gVisor — syscall interception, stronger isolation + # runtime: "kata-runtime" # Kata containers — VM-backed OCI ``` -┌──────────────────────────────────────────────────────────────┐ -│ Your LLM / Agent │ -│ (Python SDK / TS SDK / curl) │ -└──────────────────────┬───────────────────────────────────────┘ - │ HTTP / WebSocket / SSE - ▼ -┌──────────────────────────────────────────────────────────────┐ -│ ForgeVM Server │ -│ │ -│ ┌─────────┐ ┌──────────────┐ ┌─────────┐ ┌──────────┐ │ -│ │ REST API │ │ Orchestrator │ │ Store │ │ Events │ │ -│ │ Chi + │ │ Lifecycle, │ │ SQLite │ │ SSE │ │ -│ │ WebSocket│ │ TTL, Pools, │ │ WAL mode │ │ bus │ │ -│ │ + SSE │ │ Templates │ │ │ │ │ │ -│ └────┬─────┘ └──────┬───────┘ └──────────┘ └──────────┘ │ -│ │ │ │ -│ └───────┬───────┘ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ Provider Interface │ │ -│ │ ┌───────────────┐ │ │ -│ │ │ Firecracker │ │ │ -│ │ │ KVM microVMs │ │ │ -│ │ └───────┬───────┘ │ │ -│ └──────────────────────┼──────────────────────────────────┘ │ -└───────────┼──────────────────────────────────────────────────┘ - │ vsock (virtio) - ▼ -┌──────────────────────────────────────────────────────────────┐ -│ Firecracker microVM │ -│ │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ forgevm-agent (PID 1) │ │ -│ │ │ │ -│ │ • Exec commands (/bin/sh -c "...") │ │ -│ │ • Read/write files │ │ -│ │ • Stream stdout/stderr │ │ -│ │ • Length-prefixed JSON protocol over vsock │ │ -│ └──────────────────────────────────────────────────────┘ │ -│ │ -│ Dedicated kernel · Ephemeral rootfs · KVM isolated │ -└──────────────────────────────────────────────────────────────┘ -``` -**Key flow:** SDK -> REST API -> Orchestrator -> Provider -> Firecracker -> vsock -> Guest Agent -> execute -> response back +### When to use which + +| Workload | Provider | +|----------|----------| +| Untrusted user code in production | Firecracker | +| Internal tooling, developer sandboxes | Docker | +| macOS / Windows / CI | Docker (only option without KVM) | +| Maximum throughput on Linux servers | Firecracker (snapshot restore) | -**Snapshot/restore:** First spawn cold-boots (~1s) and snapshots. Every spawn after restores in ~28ms. +--- + +## CLI + +```bash +forgevm serve # start server +forgevm spawn --image python:3.12 --ttl 1h # spawn interactively +forgevm list # list all sandboxes +forgevm exec sb-a1b2c3d4 -- echo hello # run a command +forgevm kill sb-a1b2c3d4 # destroy +forgevm build-image python:3.12 # prebake Docker image → Firecracker rootfs +forgevm tui # interactive dashboard +forgevm version # print version +``` --- ## Development ```bash -make build # build server binary -make build-agent # build guest agent (static linux/amd64) -make build-all # build both -make test # run all tests -make web # build frontend -make lint # go vet -make release-build # static release binaries + checksums +make build # build server binary +make build-agent # build guest agent (static linux/amd64) +make build-all # build both +make test # go test ./... +make web # build React dashboard +make lint # go vet +make release-build # static binaries + checksums.txt ``` ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md). +[CONTRIBUTING.md](CONTRIBUTING.md) ## License diff --git a/assets/forgevm_architecture_diagram.svg b/assets/forgevm_architecture_diagram.svg new file mode 100644 index 0000000..d18afc0 --- /dev/null +++ b/assets/forgevm_architecture_diagram.svg @@ -0,0 +1,144 @@ + + + + + + + + + + + Your LLM / agent / application + Python SDK · TypeScript SDK · REST API · curl + + + + +HTTP / WebSocket / SSE + + + + + ForgeVM server + + + + + + REST API + WebSocket · SSE + + + + + + Orchestrator + Lifecycle · TTL · Pool · Templates + + + + + + SQLite + Events + State · logs · SSE bus + + + + + + + + + + + + + + + Provider interface (16 methods: spawn, exec, files, destroy ...) + + + + + + + + + + + + Firecracker + KVM · vsock + Linux 4.14+ kernel + + + + + + Docker (OCI) + runc · runsc · kata + cgroups · namespaces + + + + + + Custom + HTTP proxy + Any backend + + + + + + + + + + + + + + Firecracker microVM + + + Own Linux kernel + forgevm-agent (PID 1) + Ephemeral rootfs + ~28ms snapshot restore + + + + + + Docker container + + + /workspace/{sb-id}/ + Any OCI image + cap_drop: ALL + No KVM required + + + + + + Key properties + Pids · mem · cpu limits + Read-only rootfs + Network: none + Seccomp profile + Non-root user + Auto-TTL expiry + + + + + + Pool mode (multi-user per sandbox) + 1 VM or container serves up to N users. Each user gets /workspace/{sandbox-id}/ + Orchestrator scopes all exec WorkDir and file paths per user. Directory-level isolation. + Optional hardening: per-user UID, PID namespace, hidepid, workspace chmod 0700 + + + \ No newline at end of file diff --git a/cmd/forgevm/cmd_serve.go b/cmd/forgevm/cmd_serve.go index ac366cb..e5d92a0 100644 --- a/cmd/forgevm/cmd_serve.go +++ b/cmd/forgevm/cmd_serve.go @@ -92,6 +92,38 @@ func runServe() error { registry.Register(custom) logger.Info().Str("name", cfg.Providers.Custom.Name).Str("url", cfg.Providers.Custom.BaseURL).Msg("custom provider registered") } + if cfg.Providers.Docker.Enabled { + docker, err := providers.NewDockerProvider(providers.DockerProviderConfig{ + Socket: cfg.Providers.Docker.Socket, + Runtime: cfg.Providers.Docker.Runtime, + DefaultImage: cfg.Providers.Docker.DefaultImage, + NetworkMode: cfg.Providers.Docker.NetworkMode, + SeccompProfile: cfg.Providers.Docker.SeccompProfile, + ReadOnlyRootfs: cfg.Providers.Docker.ReadOnlyRootfs, + Memory: cfg.Providers.Docker.Memory, + CPUs: cfg.Providers.Docker.CPUs, + PidsLimit: cfg.Providers.Docker.PidsLimit, + User: cfg.Providers.Docker.User, + DroppedCaps: cfg.Providers.Docker.DroppedCaps, + AddedCaps: cfg.Providers.Docker.AddedCaps, + Tmpfs: cfg.Providers.Docker.Tmpfs, + PoolSecurity: providers.PoolSecurityProviderConfig{ + PerUserUID: cfg.Providers.Docker.PoolSecurity.PerUserUID, + PIDNamespace: cfg.Providers.Docker.PoolSecurity.PIDNamespace, + WorkspacePermissions: cfg.Providers.Docker.PoolSecurity.WorkspacePermissions, + HidePID: cfg.Providers.Docker.PoolSecurity.HidePID, + }, + }, logger) + if err != nil { + logger.Error().Err(err).Msg("failed to create docker provider") + } else { + registry.Register(docker) + logger.Info(). + Str("runtime", cfg.Providers.Docker.Runtime). + Str("network", cfg.Providers.Docker.NetworkMode). + Msg("docker provider registered") + } + } if err := registry.SetDefault(cfg.Providers.Default); err != nil { logger.Warn().Err(err).Msg("setting default provider") } diff --git a/go.mod b/go.mod index 3d80ba1..a55a0f2 100644 --- a/go.mod +++ b/go.mod @@ -20,8 +20,10 @@ require ( require ( github.com/KyleBanks/depth v1.2.1 // indirect + github.com/Microsoft/go-winio v0.4.14 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect @@ -29,14 +31,22 @@ require ( github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v27.5.1+incompatible // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/spec v0.20.9 // indirect github.com/go-openapi/swag v0.22.3 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect @@ -46,11 +56,15 @@ require ( github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mdlayher/socket v0.4.1 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect @@ -61,11 +75,16 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/swaggo/files/v2 v2.0.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/otel v1.42.0 // indirect + go.opentelemetry.io/otel/metric v1.42.0 // indirect + go.opentelemetry.io/otel/trace v1.42.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/net v0.46.0 // indirect golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.40.0 // indirect + golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.30.0 // indirect golang.org/x/tools v0.38.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index d255c03..f69f8dc 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,13 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU= +github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -30,16 +34,31 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.5.1+incompatible h1:4PYU5dnBYqRQi0294d1FBECqT9ECWeQAIfE8q4YnPY8= +github.com/docker/docker v27.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= @@ -56,6 +75,8 @@ github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= @@ -68,6 +89,9 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -97,6 +121,8 @@ github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= github.com/mdlayher/vsock v1.2.1 h1:pC1mTJTvjo1r9n9fbm7S1j04rCgCzhCOS5DY0zqHlnQ= github.com/mdlayher/vsock v1.2.1/go.mod h1:NRfCibel++DgeMD8z/hP+PPTjlNJsdPOmxcnENvE+SE= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -106,8 +132,14 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3 github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -123,6 +155,7 @@ github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWR github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -137,8 +170,10 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -156,26 +191,67 @@ github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/config/config.go b/internal/config/config.go index 784ce13..de433c9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -43,10 +43,36 @@ type ProvidersConfig struct { Default string `mapstructure:"default"` Mock MockConfig `mapstructure:"mock"` Firecracker FirecrackerConfig `mapstructure:"firecracker"` + Docker DockerConfig `mapstructure:"docker"` E2B E2BConfig `mapstructure:"e2b"` Custom CustomConfig `mapstructure:"custom"` } +type DockerConfig struct { + Enabled bool `mapstructure:"enabled"` + Socket string `mapstructure:"socket"` + Runtime string `mapstructure:"runtime"` + DefaultImage string `mapstructure:"default_image"` + NetworkMode string `mapstructure:"network_mode"` + SeccompProfile string `mapstructure:"seccomp_profile"` + ReadOnlyRootfs bool `mapstructure:"read_only_rootfs"` + Memory string `mapstructure:"memory"` + CPUs string `mapstructure:"cpus"` + PidsLimit int64 `mapstructure:"pids_limit"` + User string `mapstructure:"user"` + DroppedCaps []string `mapstructure:"dropped_caps"` + AddedCaps []string `mapstructure:"added_caps"` + Tmpfs map[string]string `mapstructure:"tmpfs"` + PoolSecurity PoolSecurityConfig `mapstructure:"pool_security"` +} + +type PoolSecurityConfig struct { + PerUserUID bool `mapstructure:"per_user_uid"` + PIDNamespace bool `mapstructure:"pid_namespace"` + WorkspacePermissions bool `mapstructure:"workspace_permissions"` + HidePID bool `mapstructure:"hidepid"` +} + type E2BConfig struct { Enabled bool `mapstructure:"enabled"` APIKey string `mapstructure:"api_key"` @@ -119,6 +145,24 @@ func setDefaults(v *viper.Viper) { v.SetDefault("providers.custom.api_key", "") v.SetDefault("providers.custom.timeout", "60s") + v.SetDefault("providers.docker.enabled", false) + v.SetDefault("providers.docker.socket", "unix:///var/run/docker.sock") + v.SetDefault("providers.docker.runtime", "runc") + v.SetDefault("providers.docker.default_image", "alpine:latest") + v.SetDefault("providers.docker.network_mode", "none") + v.SetDefault("providers.docker.seccomp_profile", "default") + v.SetDefault("providers.docker.read_only_rootfs", true) + v.SetDefault("providers.docker.memory", "512m") + v.SetDefault("providers.docker.cpus", "1") + v.SetDefault("providers.docker.pids_limit", 256) + v.SetDefault("providers.docker.user", "") + v.SetDefault("providers.docker.dropped_caps", []string{"ALL"}) + v.SetDefault("providers.docker.added_caps", []string{}) + v.SetDefault("providers.docker.pool_security.per_user_uid", false) + v.SetDefault("providers.docker.pool_security.pid_namespace", false) + v.SetDefault("providers.docker.pool_security.workspace_permissions", true) + v.SetDefault("providers.docker.pool_security.hidepid", false) + v.SetDefault("defaults.ttl", "30m") v.SetDefault("defaults.image", "alpine:latest") v.SetDefault("defaults.memory_mb", 1024) diff --git a/internal/providers/docker.go b/internal/providers/docker.go new file mode 100644 index 0000000..e8f70c2 --- /dev/null +++ b/internal/providers/docker.go @@ -0,0 +1,774 @@ +package providers + +import ( + "archive/tar" + "bytes" + "context" + "fmt" + "io" + "path" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/docker/docker/api/types/container" + dockerimage "github.com/docker/docker/api/types/image" + "github.com/docker/docker/client" + "github.com/docker/docker/pkg/stdcopy" + "github.com/rs/zerolog" +) + +// DockerProviderConfig holds all configuration for the Docker provider. +type DockerProviderConfig struct { + Socket string + Runtime string + DefaultImage string + NetworkMode string + SeccompProfile string + ReadOnlyRootfs bool + Memory string + CPUs string + PidsLimit int64 + User string + DroppedCaps []string + AddedCaps []string + Tmpfs map[string]string + PoolSecurity PoolSecurityProviderConfig +} + +// PoolSecurityProviderConfig controls per-user isolation within a shared container. +type PoolSecurityProviderConfig struct { + PerUserUID bool + PIDNamespace bool + WorkspacePermissions bool + HidePID bool +} + +type dockerSandbox struct { + id string + image string + state string +} + +// ctxChanWriter sends written bytes as StreamChunks to a channel, respecting context cancellation. +type ctxChanWriter struct { + ch chan<- StreamChunk + stream string + ctx context.Context +} + +func (w *ctxChanWriter) Write(p []byte) (int, error) { + data := make([]byte, len(p)) + copy(data, p) + select { + case w.ch <- StreamChunk{Stream: w.stream, Data: string(data)}: + return len(p), nil + case <-w.ctx.Done(): + return 0, w.ctx.Err() + } +} + +// tarFileReader extracts a single file from a tar archive returned by CopyFromContainer. +type tarFileReader struct { + *tar.Reader + closer io.Closer +} + +func (r *tarFileReader) Close() error { return r.closer.Close() } + +// DockerProvider implements Provider using Docker containers. +type DockerProvider struct { + mu sync.RWMutex + sandboxes map[string]*dockerSandbox + cli *client.Client + config DockerProviderConfig + logger zerolog.Logger +} + +// NewDockerProvider creates a new Docker provider connected to the Docker daemon. +func NewDockerProvider(cfg DockerProviderConfig, logger zerolog.Logger) (*DockerProvider, error) { + opts := []client.Opt{client.WithAPIVersionNegotiation()} + if cfg.Socket != "" { + opts = append(opts, client.WithHost(cfg.Socket)) + } + cli, err := client.NewClientWithOpts(opts...) + if err != nil { + return nil, fmt.Errorf("docker client: %w", err) + } + return &DockerProvider{ + sandboxes: make(map[string]*dockerSandbox), + cli: cli, + config: cfg, + logger: logger, + }, nil +} + +func (d *DockerProvider) Name() string { return "docker" } + +func (d *DockerProvider) Healthy(ctx context.Context) bool { + _, err := d.cli.Ping(ctx) + return err == nil +} + +func (d *DockerProvider) Spawn(ctx context.Context, opts SpawnOptions) (string, error) { + image := opts.Image + if image == "" { + image = d.config.DefaultImage + } + if image == "" { + image = "alpine:latest" + } + + if err := d.ensureImage(ctx, image); err != nil { + return "", fmt.Errorf("ensure image %q: %w", image, err) + } + + memBytes := parseMemoryBytes(d.config.Memory) + if opts.MemoryMB > 0 { + memBytes = int64(opts.MemoryMB) * 1024 * 1024 + } + + nanoCPUs := parseCPUs(d.config.CPUs) + if opts.VCPUs > 0 { + nanoCPUs = int64(opts.VCPUs) * 1e9 + } + + pidsLimit := d.config.PidsLimit + if pidsLimit <= 0 { + pidsLimit = 256 + } + + containerCfg := &container.Config{ + Image: image, + Cmd: []string{"sh", "-c", "sleep infinity 2>/dev/null || tail -f /dev/null"}, + WorkingDir: "/workspace", + Tty: false, + Labels: map[string]string{"forgevm": "true"}, + } + if d.config.User != "" { + containerCfg.User = d.config.User + } + + networkMode := d.config.NetworkMode + if networkMode == "" { + networkMode = "none" + } + + hostCfg := &container.HostConfig{ + Runtime: d.config.Runtime, + NetworkMode: container.NetworkMode(networkMode), + ReadonlyRootfs: d.config.ReadOnlyRootfs, + Resources: container.Resources{ + Memory: memBytes, + NanoCPUs: nanoCPUs, + PidsLimit: &pidsLimit, + }, + SecurityOpt: d.buildSecurityOpts(), + CapDrop: d.config.DroppedCaps, + CapAdd: d.config.AddedCaps, + Tmpfs: d.config.Tmpfs, + AutoRemove: false, + } + + resp, err := d.cli.ContainerCreate(ctx, containerCfg, hostCfg, nil, nil, "") + if err != nil { + return "", fmt.Errorf("creating container: %w", err) + } + + if err := d.cli.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil { + d.cli.ContainerRemove(ctx, resp.ID, container.RemoveOptions{Force: true}) //nolint:errcheck + return "", fmt.Errorf("starting container: %w", err) + } + + // Create workspace directory inside the container. + if _, err := d.containerExec(ctx, resp.ID, "mkdir -p /workspace"); err != nil { + d.logger.Warn().Err(err).Msg("could not create /workspace in container") + } + + d.mu.Lock() + d.sandboxes[resp.ID] = &dockerSandbox{id: resp.ID, image: image, state: "running"} + d.mu.Unlock() + + d.logger.Info().Str("container", resp.ID[:12]).Str("image", image).Msg("docker sandbox spawned") + return resp.ID, nil +} + +func (d *DockerProvider) Exec(ctx context.Context, sandboxID string, opts ExecOptions) (*ExecResult, error) { + if _, err := d.getSandbox(sandboxID); err != nil { + return nil, err + } + + workDir := opts.WorkDir + if workDir == "" { + workDir = "/workspace" + } + + shellCmd := opts.Command + for _, a := range opts.Args { + shellCmd += " " + shellQuoteDocker(a) + } + + execCfg := container.ExecOptions{ + Cmd: []string{"sh", "-c", shellCmd}, + WorkingDir: workDir, + AttachStdout: true, + AttachStderr: true, + Env: mapToEnvSlice(opts.Env), + } + + execID, err := d.cli.ContainerExecCreate(ctx, sandboxID, execCfg) + if err != nil { + return nil, fmt.Errorf("exec create: %w", err) + } + + resp, err := d.cli.ContainerExecAttach(ctx, execID.ID, container.ExecStartOptions{}) + if err != nil { + return nil, fmt.Errorf("exec attach: %w", err) + } + defer resp.Close() + + var stdout, stderr bytes.Buffer + if _, err := stdcopy.StdCopy(&stdout, &stderr, resp.Reader); err != nil && err != io.EOF { + d.logger.Debug().Err(err).Msg("stdcopy exec error") + } + + inspect, err := d.cli.ContainerExecInspect(ctx, execID.ID) + if err != nil { + return nil, fmt.Errorf("exec inspect: %w", err) + } + + return &ExecResult{ + ExitCode: inspect.ExitCode, + Stdout: stdout.String(), + Stderr: stderr.String(), + }, nil +} + +func (d *DockerProvider) ExecStream(ctx context.Context, sandboxID string, opts ExecOptions) (<-chan StreamChunk, error) { + if _, err := d.getSandbox(sandboxID); err != nil { + return nil, err + } + + workDir := opts.WorkDir + if workDir == "" { + workDir = "/workspace" + } + + shellCmd := opts.Command + for _, a := range opts.Args { + shellCmd += " " + shellQuoteDocker(a) + } + + execCfg := container.ExecOptions{ + Cmd: []string{"sh", "-c", shellCmd}, + WorkingDir: workDir, + AttachStdout: true, + AttachStderr: true, + Env: mapToEnvSlice(opts.Env), + } + + execID, err := d.cli.ContainerExecCreate(ctx, sandboxID, execCfg) + if err != nil { + return nil, fmt.Errorf("exec create: %w", err) + } + + resp, err := d.cli.ContainerExecAttach(ctx, execID.ID, container.ExecStartOptions{}) + if err != nil { + return nil, fmt.Errorf("exec attach: %w", err) + } + + ch := make(chan StreamChunk, 64) + go func() { + defer close(ch) + defer resp.Close() + stdoutW := &ctxChanWriter{ch: ch, stream: "stdout", ctx: ctx} + stderrW := &ctxChanWriter{ch: ch, stream: "stderr", ctx: ctx} + if _, err := stdcopy.StdCopy(stdoutW, stderrW, resp.Reader); err != nil && err != io.EOF { + d.logger.Debug().Err(err).Msg("stdcopy stream error") + } + }() + + return ch, nil +} + +func (d *DockerProvider) WriteFile(ctx context.Context, sandboxID string, path string, content io.Reader, mode string) error { + if _, err := d.getSandbox(sandboxID); err != nil { + return err + } + + path = cleanContainerPath(path) + dir := filepath.Dir(path) + + data, err := io.ReadAll(content) + if err != nil { + return fmt.Errorf("reading content: %w", err) + } + + fileMode := int64(0644) + if mode != "" { + if parsed, err := strconv.ParseInt(mode, 8, 32); err == nil { + fileMode = parsed + } + } + + // Ensure parent directory exists. + if _, err := d.containerExec(ctx, sandboxID, "mkdir -p "+shellQuoteDocker(dir)); err != nil { + d.logger.Warn().Err(err).Str("dir", dir).Msg("mkdir -p failed") + } + + // Build tar archive in memory containing a single file. + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + hdr := &tar.Header{ + Name: filepath.Base(path), + Mode: fileMode, + Size: int64(len(data)), + Typeflag: tar.TypeReg, + ModTime: time.Now(), + } + if err := tw.WriteHeader(hdr); err != nil { + return fmt.Errorf("tar header: %w", err) + } + if _, err := tw.Write(data); err != nil { + return fmt.Errorf("tar write: %w", err) + } + if err := tw.Close(); err != nil { + return fmt.Errorf("tar close: %w", err) + } + + return d.cli.CopyToContainer(ctx, sandboxID, dir, &buf, container.CopyToContainerOptions{}) +} + +func (d *DockerProvider) ReadFile(ctx context.Context, sandboxID string, path string) (io.ReadCloser, error) { + if _, err := d.getSandbox(sandboxID); err != nil { + return nil, err + } + + path = cleanContainerPath(path) + + rc, _, err := d.cli.CopyFromContainer(ctx, sandboxID, path) + if err != nil { + return nil, fmt.Errorf("copy from container: %w", err) + } + + // CopyFromContainer returns a tar archive; extract the first entry. + tr := tar.NewReader(rc) + if _, err := tr.Next(); err != nil { + rc.Close() + return nil, fmt.Errorf("reading tar entry: %w", err) + } + return &tarFileReader{Reader: tr, closer: rc}, nil +} + +func (d *DockerProvider) ListFiles(ctx context.Context, sandboxID string, path string) ([]FileInfo, error) { + if _, err := d.getSandbox(sandboxID); err != nil { + return nil, err + } + + path = cleanContainerPath(path) + + // Use find + stat with | separator for structured output: name|size|octal-mode|type|mtime-epoch + cmd := fmt.Sprintf( + `find %s -maxdepth 1 -mindepth 1 2>/dev/null | while IFS= read -r f; do stat -c '%%n|%%s|%%a|%%F|%%Y' "$f" 2>/dev/null; done`, + shellQuoteDocker(path), + ) + result, err := d.containerExec(ctx, sandboxID, cmd) + if err != nil { + return nil, fmt.Errorf("listing directory: %w", err) + } + + return parseStatOutput(result.Stdout), nil +} + +func (d *DockerProvider) DeleteFile(ctx context.Context, sandboxID string, path string, recursive bool) error { + if _, err := d.getSandbox(sandboxID); err != nil { + return err + } + + path = cleanContainerPath(path) + flag := "-f" + if recursive { + flag = "-rf" + } + result, err := d.containerExec(ctx, sandboxID, "rm "+flag+" "+shellQuoteDocker(path)) + if err != nil { + return fmt.Errorf("delete file: %w", err) + } + if result.ExitCode != 0 { + return fmt.Errorf("rm failed (exit %d): %s", result.ExitCode, result.Stderr) + } + return nil +} + +func (d *DockerProvider) MoveFile(ctx context.Context, sandboxID string, oldPath, newPath string) error { + if _, err := d.getSandbox(sandboxID); err != nil { + return err + } + + oldPath = cleanContainerPath(oldPath) + newPath = cleanContainerPath(newPath) + newDir := filepath.Dir(newPath) + + cmd := fmt.Sprintf("mkdir -p %s && mv %s %s", shellQuoteDocker(newDir), shellQuoteDocker(oldPath), shellQuoteDocker(newPath)) + result, err := d.containerExec(ctx, sandboxID, cmd) + if err != nil { + return fmt.Errorf("move file: %w", err) + } + if result.ExitCode != 0 { + return fmt.Errorf("mv failed (exit %d): %s", result.ExitCode, result.Stderr) + } + return nil +} + +func (d *DockerProvider) ChmodFile(ctx context.Context, sandboxID string, path string, mode string) error { + if _, err := d.getSandbox(sandboxID); err != nil { + return err + } + + path = cleanContainerPath(path) + result, err := d.containerExec(ctx, sandboxID, "chmod "+shellQuoteDocker(mode)+" "+shellQuoteDocker(path)) + if err != nil { + return fmt.Errorf("chmod file: %w", err) + } + if result.ExitCode != 0 { + return fmt.Errorf("chmod failed (exit %d): %s", result.ExitCode, result.Stderr) + } + return nil +} + +func (d *DockerProvider) StatFile(ctx context.Context, sandboxID string, path string) (*FileInfo, error) { + if _, err := d.getSandbox(sandboxID); err != nil { + return nil, err + } + + path = cleanContainerPath(path) + // Use ContainerStatPath for metadata, then exec stat for mode details. + stat, err := d.cli.ContainerStatPath(ctx, sandboxID, path) + if err != nil { + return nil, fmt.Errorf("stat path: %w", err) + } + + // Get octal mode and type via exec stat. + result, err := d.containerExec(ctx, sandboxID, "stat -c '%a %F %Y' "+shellQuoteDocker(path)) + modeStr := "" + isDir := stat.Mode.IsDir() + if err == nil && result.ExitCode == 0 { + parts := strings.Fields(strings.TrimSpace(result.Stdout)) + if len(parts) >= 1 { + modeStr = parts[0] + } + } + + return &FileInfo{ + Path: path, + Size: stat.Size, + Mode: modeStr, + IsDir: isDir, + ModTime: stat.Mtime.UTC().Format("2006-01-02T15:04:05Z"), + }, nil +} + +func (d *DockerProvider) GlobFiles(ctx context.Context, sandboxID string, pattern string) ([]string, error) { + if _, err := d.getSandbox(sandboxID); err != nil { + return nil, err + } + + // Pass the pattern via an env variable to avoid shell interpolation of caller-supplied input. + cmd := `sh -c 'for f in $GLOB_PATTERN; do [ -e "$f" ] && echo "$f"; done 2>/dev/null'` + execCfg := container.ExecOptions{ + Cmd: []string{"sh", "-c", cmd}, + AttachStdout: true, + AttachStderr: true, + Env: []string{"GLOB_PATTERN=" + pattern}, + } + execID, err := d.cli.ContainerExecCreate(ctx, sandboxID, execCfg) + if err != nil { + return nil, fmt.Errorf("glob exec create: %w", err) + } + resp, err := d.cli.ContainerExecAttach(ctx, execID.ID, container.ExecStartOptions{}) + if err != nil { + return nil, fmt.Errorf("glob exec attach: %w", err) + } + defer resp.Close() + + var stdout bytes.Buffer + stdcopy.StdCopy(&stdout, io.Discard, resp.Reader) //nolint:errcheck + + var matches []string + for _, line := range strings.Split(strings.TrimSpace(stdout.String()), "\n") { + if line = strings.TrimSpace(line); line != "" { + matches = append(matches, line) + } + } + return matches, nil +} + +func (d *DockerProvider) Status(ctx context.Context, sandboxID string) (*SandboxStatus, error) { + info, err := d.cli.ContainerInspect(ctx, sandboxID) + if err != nil { + return nil, fmt.Errorf("inspect container %q: %w", sandboxID, err) + } + + state := "unknown" + if info.State != nil { + switch { + case info.State.Running: + state = "running" + case info.State.Paused: + state = "paused" + case info.State.Restarting: + state = "restarting" + default: + state = "stopped" + } + } + return &SandboxStatus{ID: sandboxID, State: state}, nil +} + +func (d *DockerProvider) Destroy(ctx context.Context, sandboxID string) error { + // Always remove from the in-memory map, even if Docker removal fails, + // so subsequent getSandbox calls return a clean "not found" error. + d.mu.Lock() + delete(d.sandboxes, sandboxID) + d.mu.Unlock() + + timeout := 5 + if err := d.cli.ContainerStop(ctx, sandboxID, container.StopOptions{Timeout: &timeout}); err != nil { + d.logger.Debug().Err(err).Msg("container stop (continuing with remove)") + } + if err := d.cli.ContainerRemove(ctx, sandboxID, container.RemoveOptions{Force: true}); err != nil { + return fmt.Errorf("removing container %q: %w", sandboxID, err) + } + + short := sandboxID + if len(short) > 12 { + short = short[:12] + } + d.logger.Info().Str("container", short).Msg("docker sandbox destroyed") + return nil +} + +func (d *DockerProvider) ConsoleLog(ctx context.Context, sandboxID string, lines int) ([]string, error) { + if _, err := d.getSandbox(sandboxID); err != nil { + return nil, err + } + + tail := "all" + if lines > 0 { + tail = strconv.Itoa(lines) + } + + rc, err := d.cli.ContainerLogs(ctx, sandboxID, container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + Tail: tail, + }) + if err != nil { + return nil, fmt.Errorf("container logs: %w", err) + } + defer rc.Close() + + var stdout, stderr bytes.Buffer + stdcopy.StdCopy(&stdout, &stderr, rc) //nolint:errcheck + + combined := stdout.String() + stderr.String() + raw := strings.Split(strings.TrimRight(combined, "\n"), "\n") + result := make([]string, 0, len(raw)) + for _, line := range raw { + if line != "" { + result = append(result, line) + } + } + return result, nil +} + +// --------------------------------------------------------------------------- +// Private helpers +// --------------------------------------------------------------------------- + +func (d *DockerProvider) getSandbox(id string) (*dockerSandbox, error) { + d.mu.RLock() + defer d.mu.RUnlock() + sb, ok := d.sandboxes[id] + if !ok { + return nil, fmt.Errorf("sandbox %q not found (may have been destroyed)", id) + } + return sb, nil +} + +// containerExec runs a one-shot command inside the container and returns the result. +func (d *DockerProvider) containerExec(ctx context.Context, containerID string, cmd string) (*ExecResult, error) { + execCfg := container.ExecOptions{ + Cmd: []string{"sh", "-c", cmd}, + AttachStdout: true, + AttachStderr: true, + } + execID, err := d.cli.ContainerExecCreate(ctx, containerID, execCfg) + if err != nil { + return nil, err + } + resp, err := d.cli.ContainerExecAttach(ctx, execID.ID, container.ExecStartOptions{}) + if err != nil { + return nil, err + } + defer resp.Close() + + var stdout, stderr bytes.Buffer + stdcopy.StdCopy(&stdout, &stderr, resp.Reader) //nolint:errcheck + + inspect, err := d.cli.ContainerExecInspect(ctx, execID.ID) + if err != nil { + return nil, err + } + return &ExecResult{ + ExitCode: inspect.ExitCode, + Stdout: stdout.String(), + Stderr: stderr.String(), + }, nil +} + +// ensureImage pulls the image if it is not already present in the local daemon cache. +func (d *DockerProvider) ensureImage(ctx context.Context, imageName string) error { + // Check local cache first to avoid unnecessary registry calls. + images, err := d.cli.ImageList(ctx, dockerimage.ListOptions{}) + if err == nil { + for _, img := range images { + for _, tag := range img.RepoTags { + if tag == imageName { + return nil + } + } + } + } + + rc, err := d.cli.ImagePull(ctx, imageName, dockerimage.PullOptions{}) + if err != nil { + return fmt.Errorf("pulling image: %w", err) + } + defer rc.Close() + + if _, err := io.Copy(io.Discard, rc); err != nil { + return fmt.Errorf("consuming pull stream (partial pull): %w", err) + } + return nil +} + +func (d *DockerProvider) buildSecurityOpts() []string { + if d.config.SeccompProfile == "" || d.config.SeccompProfile == "default" { + return nil + } + return []string{"seccomp=" + d.config.SeccompProfile} +} + +// cleanContainerPath returns a cleaned absolute container path using POSIX path semantics +// (path.Clean, not filepath.Clean) since containers are always Linux. +func cleanContainerPath(p string) string { + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + return path.Clean(p) +} + +// scopedWorkspacePath returns a workspace path scoped to a sandbox, preventing traversal. +func scopedWorkspacePath(sandboxID, p string) string { + base := "/workspace/" + sandboxID + clean := path.Clean("/" + p) + if strings.HasPrefix(clean, base+"/") || clean == base { + return clean + } + return base + "/" + filepath.Base(clean) +} + +// parseMemoryBytes converts a human-readable memory string to bytes. +func parseMemoryBytes(s string) int64 { + if s == "" { + return 0 + } + s = strings.TrimSpace(strings.ToLower(s)) + if s == "0" { + return 0 + } + multipliers := map[string]int64{ + "k": 1024, + "m": 1024 * 1024, + "g": 1024 * 1024 * 1024, + } + for suffix, mult := range multipliers { + if strings.HasSuffix(s, suffix) { + n, err := strconv.ParseInt(s[:len(s)-len(suffix)], 10, 64) + if err != nil { + return 0 + } + return n * mult + } + } + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0 + } + return n +} + +// parseCPUs converts a CPU count string to Docker NanoCPUs. +func parseCPUs(s string) int64 { + if s == "" || s == "0" { + return 0 + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0 + } + return int64(f * 1e9) +} + +// mapToEnvSlice converts a map of env vars to a KEY=VALUE slice. +func mapToEnvSlice(env map[string]string) []string { + if len(env) == 0 { + return nil + } + result := make([]string, 0, len(env)) + for k, v := range env { + result = append(result, k+"="+v) + } + return result +} + +// shellQuoteDocker wraps a string in single quotes for safe shell interpolation. +func shellQuoteDocker(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" +} + +// parseStatOutput parses output from: +// +// find /path ... | while read -r f; do stat -c '%n|%s|%a|%F|%Y' "$f"; done +// +// Each line: name|size|octal-mode|type|mtime-epoch +// Uses SplitN(5) so that filenames containing '|' don't break parsing. +func parseStatOutput(output string) []FileInfo { + var files []FileInfo + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, "|", 5) + if len(parts) < 5 { + continue + } + size, _ := strconv.ParseInt(parts[1], 10, 64) + epoch, _ := strconv.ParseInt(parts[4], 10, 64) + modTime := time.Unix(epoch, 0).UTC().Format("2006-01-02T15:04:05Z") + isDir := strings.Contains(parts[3], "directory") + files = append(files, FileInfo{ + Path: parts[0], + Size: size, + Mode: parts[2], + IsDir: isDir, + ModTime: modTime, + }) + } + return files +} + diff --git a/internal/providers/docker_test.go b/internal/providers/docker_test.go new file mode 100644 index 0000000..55852b5 --- /dev/null +++ b/internal/providers/docker_test.go @@ -0,0 +1,611 @@ +package providers + +import ( + "context" + "io" + "os" + "os/exec" + "strings" + "testing" + "time" + + "github.com/rs/zerolog" +) + +func testLogger() zerolog.Logger { + return zerolog.New(os.Stderr).With().Timestamp().Logger().Level(zerolog.DebugLevel) +} + +// --------------------------------------------------------------------------- +// Unit tests — no Docker daemon required +// --------------------------------------------------------------------------- + +func TestDockerProviderName(t *testing.T) { + p := &DockerProvider{} + if got := p.Name(); got != "docker" { + t.Errorf("Name() = %q, want %q", got, "docker") + } +} + +func TestParseMemoryBytes(t *testing.T) { + tests := []struct { + input string + want int64 + }{ + {"512m", 512 * 1024 * 1024}, + {"1g", 1024 * 1024 * 1024}, + {"256M", 256 * 1024 * 1024}, + {"2G", 2 * 1024 * 1024 * 1024}, + {"1024k", 1024 * 1024}, + {"0", 0}, + {"", 0}, + {"invalid", 0}, + } + for _, tc := range tests { + got := parseMemoryBytes(tc.input) + if got != tc.want { + t.Errorf("parseMemoryBytes(%q) = %d, want %d", tc.input, got, tc.want) + } + } +} + +func TestParseCPUs(t *testing.T) { + tests := []struct { + input string + want int64 + }{ + {"1", 1e9}, + {"2", 2e9}, + {"0.5", 5e8}, + {"0", 0}, + {"", 0}, + } + for _, tc := range tests { + got := parseCPUs(tc.input) + if got != tc.want { + t.Errorf("parseCPUs(%q) = %d, want %d", tc.input, got, tc.want) + } + } +} + +func TestShellQuoteDocker(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"hello", "'hello'"}, + {"hello world", "'hello world'"}, + {"it's", "'it'\"'\"'s'"}, + {"", "''"}, + {"/path/to/file.txt", "'/path/to/file.txt'"}, + } + for _, tc := range tests { + got := shellQuoteDocker(tc.input) + if got != tc.want { + t.Errorf("shellQuoteDocker(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} + +func TestScopedPathTraversalPrevention(t *testing.T) { + sandboxID := "sb-a1b2c3d4" + base := "/workspace/" + sandboxID + + attacks := []string{ + "../../etc/passwd", + "../sb-other/secret.txt", + "/workspace/sb-other/file.py", + "/../../../root/.ssh/id_rsa", + "/workspace/../../../etc/shadow", + "....//....//etc/passwd", + } + for _, attack := range attacks { + result := scopedWorkspacePath(sandboxID, attack) + if !strings.HasPrefix(result, base) { + t.Errorf("traversal escape: input=%q result=%q must start with %q", attack, result, base) + } + } +} + +func TestDockerHealthy_NoDocker(t *testing.T) { + p, err := NewDockerProvider(DockerProviderConfig{ + Socket: "unix:///nonexistent.sock", + }, testLogger()) + if err != nil { + t.Fatalf("NewDockerProvider: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if p.Healthy(ctx) { + t.Error("Healthy() should return false for nonexistent socket") + } +} + +// --------------------------------------------------------------------------- +// Integration tests — require Docker daemon +// --------------------------------------------------------------------------- + +func skipIfNoDocker(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available in PATH") + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + p, err := newTestDockerProvider(t) + if err != nil { + t.Skip("docker daemon not reachable:", err) + } + if !p.Healthy(ctx) { + t.Skip("docker daemon not healthy") + } +} + +func newTestDockerProvider(t *testing.T) (*DockerProvider, error) { + t.Helper() + return NewDockerProvider(DockerProviderConfig{ + DefaultImage: "alpine:latest", + NetworkMode: "none", + ReadOnlyRootfs: false, // easier for test writes + Memory: "256m", + CPUs: "1", + PidsLimit: 256, + }, testLogger()) +} + +func spawnTestSandbox(t *testing.T, p *DockerProvider) string { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + id, err := p.Spawn(ctx, SpawnOptions{Image: "alpine:latest"}) + if err != nil { + t.Fatalf("spawn: %v", err) + } + t.Cleanup(func() { + ctx2, cancel2 := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel2() + p.Destroy(ctx2, id) //nolint:errcheck + }) + return id +} + +func TestDockerIntegration_Healthy(t *testing.T) { + skipIfNoDocker(t) + p, err := newTestDockerProvider(t) + if err != nil { + t.Fatalf("provider: %v", err) + } + ctx := context.Background() + if !p.Healthy(ctx) { + t.Error("Healthy() = false, want true") + } +} + +func TestDockerIntegration_SpawnAndDestroy(t *testing.T) { + skipIfNoDocker(t) + p, err := newTestDockerProvider(t) + if err != nil { + t.Fatalf("provider: %v", err) + } + ctx := context.Background() + + id, err := p.Spawn(ctx, SpawnOptions{Image: "alpine:latest"}) + if err != nil { + t.Fatalf("spawn: %v", err) + } + if id == "" { + t.Fatal("spawn returned empty id") + } + + status, err := p.Status(ctx, id) + if err != nil { + t.Fatalf("status: %v", err) + } + if status.State != "running" { + t.Errorf("state = %q, want %q", status.State, "running") + } + + if err := p.Destroy(ctx, id); err != nil { + t.Fatalf("destroy: %v", err) + } + _, err = p.Status(ctx, id) + if err == nil { + t.Error("status after destroy: expected error, got nil") + } +} + +func TestDockerIntegration_Exec(t *testing.T) { + skipIfNoDocker(t) + p, err := newTestDockerProvider(t) + if err != nil { + t.Fatalf("provider: %v", err) + } + id := spawnTestSandbox(t, p) + ctx := context.Background() + + result, err := p.Exec(ctx, id, ExecOptions{Command: "echo hello"}) + if err != nil { + t.Fatalf("exec: %v", err) + } + if !strings.Contains(result.Stdout, "hello") { + t.Errorf("stdout = %q, want to contain %q", result.Stdout, "hello") + } + if result.ExitCode != 0 { + t.Errorf("exit_code = %d, want 0", result.ExitCode) + } +} + +func TestDockerIntegration_ExecWithEnvAndWorkDir(t *testing.T) { + skipIfNoDocker(t) + p, err := newTestDockerProvider(t) + if err != nil { + t.Fatalf("provider: %v", err) + } + id := spawnTestSandbox(t, p) + ctx := context.Background() + + result, err := p.Exec(ctx, id, ExecOptions{ + Command: "echo $MY_VAR from $(pwd)", + Env: map[string]string{"MY_VAR": "test123"}, + WorkDir: "/workspace", + }) + if err != nil { + t.Fatalf("exec: %v", err) + } + if !strings.Contains(result.Stdout, "test123") { + t.Errorf("env not passed: stdout = %q", result.Stdout) + } + if !strings.Contains(result.Stdout, "/workspace") { + t.Errorf("workdir not set: stdout = %q", result.Stdout) + } +} + +func TestDockerIntegration_ExecNonZeroExit(t *testing.T) { + skipIfNoDocker(t) + p, err := newTestDockerProvider(t) + if err != nil { + t.Fatalf("provider: %v", err) + } + id := spawnTestSandbox(t, p) + ctx := context.Background() + + result, err := p.Exec(ctx, id, ExecOptions{Command: "exit 42"}) + if err != nil { + t.Fatalf("exec: %v", err) + } + if result.ExitCode != 42 { + t.Errorf("exit_code = %d, want 42", result.ExitCode) + } +} + +func TestDockerIntegration_ExecStderr(t *testing.T) { + skipIfNoDocker(t) + p, err := newTestDockerProvider(t) + if err != nil { + t.Fatalf("provider: %v", err) + } + id := spawnTestSandbox(t, p) + ctx := context.Background() + + result, err := p.Exec(ctx, id, ExecOptions{Command: "echo error >&2"}) + if err != nil { + t.Fatalf("exec: %v", err) + } + if !strings.Contains(result.Stderr, "error") { + t.Errorf("stderr = %q, want to contain %q", result.Stderr, "error") + } +} + +func TestDockerIntegration_ExecStream(t *testing.T) { + skipIfNoDocker(t) + p, err := newTestDockerProvider(t) + if err != nil { + t.Fatalf("provider: %v", err) + } + id := spawnTestSandbox(t, p) + ctx := context.Background() + + ch, err := p.ExecStream(ctx, id, ExecOptions{ + Command: "for i in 1 2 3; do echo line_$i; done", + }) + if err != nil { + t.Fatalf("exec stream: %v", err) + } + + var combined strings.Builder + for chunk := range ch { + combined.WriteString(chunk.Data) + } + out := combined.String() + for _, line := range []string{"line_1", "line_2", "line_3"} { + if !strings.Contains(out, line) { + t.Errorf("stream output missing %q: got %q", line, out) + } + } +} + +func TestDockerIntegration_WriteReadFile(t *testing.T) { + skipIfNoDocker(t) + p, err := newTestDockerProvider(t) + if err != nil { + t.Fatalf("provider: %v", err) + } + id := spawnTestSandbox(t, p) + ctx := context.Background() + + content := "hello docker provider" + err = p.WriteFile(ctx, id, "/workspace/test.txt", strings.NewReader(content), "0644") + if err != nil { + t.Fatalf("write file: %v", err) + } + + rc, err := p.ReadFile(ctx, id, "/workspace/test.txt") + if err != nil { + t.Fatalf("read file: %v", err) + } + defer rc.Close() + + data, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("read all: %v", err) + } + if string(data) != content { + t.Errorf("read = %q, want %q", string(data), content) + } +} + +func TestDockerIntegration_ListFiles(t *testing.T) { + skipIfNoDocker(t) + p, err := newTestDockerProvider(t) + if err != nil { + t.Fatalf("provider: %v", err) + } + id := spawnTestSandbox(t, p) + ctx := context.Background() + + for _, name := range []string{"a.txt", "b.txt", "c.txt"} { + if err := p.WriteFile(ctx, id, "/workspace/"+name, strings.NewReader("x"), "0644"); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + + files, err := p.ListFiles(ctx, id, "/workspace") + if err != nil { + t.Fatalf("list files: %v", err) + } + if len(files) < 3 { + t.Errorf("list returned %d files, want at least 3", len(files)) + } +} + +func TestDockerIntegration_DeleteFile(t *testing.T) { + skipIfNoDocker(t) + p, err := newTestDockerProvider(t) + if err != nil { + t.Fatalf("provider: %v", err) + } + id := spawnTestSandbox(t, p) + ctx := context.Background() + + if err := p.WriteFile(ctx, id, "/workspace/del.txt", strings.NewReader("bye"), "0644"); err != nil { + t.Fatalf("write: %v", err) + } + if err := p.DeleteFile(ctx, id, "/workspace/del.txt", false); err != nil { + t.Fatalf("delete: %v", err) + } + _, err = p.ReadFile(ctx, id, "/workspace/del.txt") + if err == nil { + t.Error("read after delete: expected error, got nil") + } +} + +func TestDockerIntegration_MoveFile(t *testing.T) { + skipIfNoDocker(t) + p, err := newTestDockerProvider(t) + if err != nil { + t.Fatalf("provider: %v", err) + } + id := spawnTestSandbox(t, p) + ctx := context.Background() + + if err := p.WriteFile(ctx, id, "/workspace/src.txt", strings.NewReader("move me"), "0644"); err != nil { + t.Fatalf("write: %v", err) + } + if err := p.MoveFile(ctx, id, "/workspace/src.txt", "/workspace/dst.txt"); err != nil { + t.Fatalf("move: %v", err) + } + + rc, err := p.ReadFile(ctx, id, "/workspace/dst.txt") + if err != nil { + t.Fatalf("read dst: %v", err) + } + defer rc.Close() + data, _ := io.ReadAll(rc) + if string(data) != "move me" { + t.Errorf("dst content = %q, want %q", string(data), "move me") + } + + _, err = p.ReadFile(ctx, id, "/workspace/src.txt") + if err == nil { + t.Error("src still exists after move") + } +} + +func TestDockerIntegration_ChmodFile(t *testing.T) { + skipIfNoDocker(t) + p, err := newTestDockerProvider(t) + if err != nil { + t.Fatalf("provider: %v", err) + } + id := spawnTestSandbox(t, p) + ctx := context.Background() + + if err := p.WriteFile(ctx, id, "/workspace/script.sh", strings.NewReader("#!/bin/sh\necho hi"), "0644"); err != nil { + t.Fatalf("write: %v", err) + } + if err := p.ChmodFile(ctx, id, "/workspace/script.sh", "0755"); err != nil { + t.Fatalf("chmod: %v", err) + } + + fi, err := p.StatFile(ctx, id, "/workspace/script.sh") + if err != nil { + t.Fatalf("stat: %v", err) + } + if !strings.Contains(fi.Mode, "7") && !strings.Contains(fi.Mode, "rwx") { + t.Errorf("mode after chmod = %q, expected executable bits", fi.Mode) + } +} + +func TestDockerIntegration_StatFile(t *testing.T) { + skipIfNoDocker(t) + p, err := newTestDockerProvider(t) + if err != nil { + t.Fatalf("provider: %v", err) + } + id := spawnTestSandbox(t, p) + ctx := context.Background() + + content := "stat test content" + if err := p.WriteFile(ctx, id, "/workspace/stat.txt", strings.NewReader(content), "0644"); err != nil { + t.Fatalf("write: %v", err) + } + + fi, err := p.StatFile(ctx, id, "/workspace/stat.txt") + if err != nil { + t.Fatalf("stat: %v", err) + } + if fi.Size != int64(len(content)) { + t.Errorf("size = %d, want %d", fi.Size, len(content)) + } + if fi.IsDir { + t.Error("IsDir should be false for a file") + } +} + +func TestDockerIntegration_GlobFiles(t *testing.T) { + skipIfNoDocker(t) + p, err := newTestDockerProvider(t) + if err != nil { + t.Fatalf("provider: %v", err) + } + id := spawnTestSandbox(t, p) + ctx := context.Background() + + for _, name := range []string{"a.py", "b.py", "c.txt"} { + if err := p.WriteFile(ctx, id, "/workspace/"+name, strings.NewReader("x"), "0644"); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + + matches, err := p.GlobFiles(ctx, id, "/workspace/*.py") + if err != nil { + t.Fatalf("glob: %v", err) + } + if len(matches) != 2 { + t.Errorf("glob returned %d matches, want 2: %v", len(matches), matches) + } +} + +func TestDockerIntegration_ConsoleLog(t *testing.T) { + skipIfNoDocker(t) + p, err := newTestDockerProvider(t) + if err != nil { + t.Fatalf("provider: %v", err) + } + id := spawnTestSandbox(t, p) + ctx := context.Background() + + // Produce some output + p.Exec(ctx, id, ExecOptions{Command: "echo console_test_output"}) //nolint:errcheck + + lines, err := p.ConsoleLog(ctx, id, 100) + if err != nil { + t.Fatalf("console log: %v", err) + } + _ = lines // log may be empty for exec output that isn't captured +} + +func TestDockerIntegration_StatusNotFound(t *testing.T) { + skipIfNoDocker(t) + p, err := newTestDockerProvider(t) + if err != nil { + t.Fatalf("provider: %v", err) + } + ctx := context.Background() + _, err = p.Status(ctx, "nonexistent-container-id-12345") + if err == nil { + t.Error("expected error for nonexistent container") + } +} + +func TestDockerIntegration_DestroyNotFound(t *testing.T) { + skipIfNoDocker(t) + p, err := newTestDockerProvider(t) + if err != nil { + t.Fatalf("provider: %v", err) + } + ctx := context.Background() + err = p.Destroy(ctx, "nonexistent-container-id-12345") + if err == nil { + t.Error("expected error for nonexistent container") + } +} + +func TestDockerIntegration_PoolWorkspaceIsolation(t *testing.T) { + skipIfNoDocker(t) + p, err := newTestDockerProvider(t) + if err != nil { + t.Fatalf("provider: %v", err) + } + ctx := context.Background() + vmID := spawnTestSandbox(t, p) + + userA, userB := "sb-aaaa0001", "sb-bbbb0002" + + // Create workspace dirs + p.Exec(ctx, vmID, ExecOptions{Command: "mkdir -p /workspace/" + userA}) //nolint:errcheck + p.Exec(ctx, vmID, ExecOptions{Command: "mkdir -p /workspace/" + userB}) //nolint:errcheck + + // User A writes secret + p.Exec(ctx, vmID, ExecOptions{Command: "echo TOP_SECRET > /workspace/" + userA + "/secret.txt"}) //nolint:errcheck + + // User B scoped to their workspace should not see User A's files + result, err := p.Exec(ctx, vmID, ExecOptions{ + Command: "ls /workspace/" + userB + "/", + WorkDir: "/workspace/" + userB, + }) + if err != nil { + t.Fatalf("exec: %v", err) + } + if strings.Contains(result.Stdout, "secret.txt") { + t.Error("User B should NOT see User A files in their workspace") + } +} + +func TestDockerIntegration_ConcurrentUsers(t *testing.T) { + skipIfNoDocker(t) + p, err := newTestDockerProvider(t) + if err != nil { + t.Fatalf("provider: %v", err) + } + ctx := context.Background() + vmID := spawnTestSandbox(t, p) + + users := []string{"sb-u001", "sb-u002", "sb-u003"} + for _, u := range users { + p.Exec(ctx, vmID, ExecOptions{Command: "mkdir -p /workspace/" + u}) //nolint:errcheck + p.Exec(ctx, vmID, ExecOptions{Command: "echo " + u + " > /workspace/" + u + "/id.txt"}) //nolint:errcheck + } + + for _, u := range users { + result, err := p.Exec(ctx, vmID, ExecOptions{ + Command: "cat /workspace/" + u + "/id.txt", + }) + if err != nil { + t.Fatalf("exec for user %s: %v", u, err) + } + if !strings.Contains(strings.TrimSpace(result.Stdout), u) { + t.Errorf("user %s identity mismatch: stdout=%q", u, result.Stdout) + } + } +}