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 @@
-
---
-## 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.
+
---
-## 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)
+ }
+ }
+}