workbench-sandbox
by jayrcaraan
README.md
# workbench-sandbox
**Gives agent-built apps an address, an identity and a durable home.**
An [MCP](https://modelcontextprotocol.io) server for Kubernetes. An agent calls a handful of tools and gets an
isolated runtime it can write files in, run commands in, start a process in, and **publish an app from** at its own
authenticated URL. The user's files live in a durable home that outlives any runtime.
It is the runtime half of [`workbench`](../workbench), a spike about letting an agent build the frame and then
step aside while people fill it in.
```mermaid
%%{init: {"theme":"base","themeVariables":{"fontFamily":"Poppins, ui-sans-serif, system-ui, sans-serif","fontSize":"15px","primaryColor":"#ecebf7","primaryBorderColor":"#3d3f73","primaryTextColor":"#2b2c52","lineColor":"#3d3f73","secondaryColor":"#fdeee6","tertiaryColor":"#f7f6fb","clusterBkg":"#faf9fc","clusterBorder":"#cfcbe0","actorBkg":"#ecebf7","actorBorder":"#3d3f73","actorTextColor":"#2b2c52","actorLineColor":"#9a98b8","signalColor":"#3d3f73","signalTextColor":"#2b2c52","noteBkgColor":"#fdeee6","noteBorderColor":"#e0693a","noteTextColor":"#2b2c52"}}}%%
flowchart LR
agent(["Agent"]) -- "MCP" --> srv["workbench-sandbox<br/>MCP server"]
srv -- "creates" --> proc
subgraph rt["Isolated runtime · one pod per session"]
proc["Your processes"]
gw["App gateway · Caddy<br/>app.sandbox.domain<br/>token per request"]
home["/home/sandbox"]
proc --- gw
end
home <-- "sync" --> vol[("Durable home<br/>PVC or S3 prefix, per user")]
srv -. "TTL reaper removes expired runtimes<br/>homes are kept" .-> rt
classDef ctl fill:#ecebf7,stroke:#3d3f73,stroke-width:2px,color:#2b2c52;
classDef data fill:#fdeee6,stroke:#e0693a,stroke-width:2px,color:#2b2c52;
class agent,srv ctl;
class proc,gw,home,vol data;
```
## What it does
| | |
|---|---|
| **Isolation** | each session gets its own pod; with gVisor (`RuntimeClass: gvisor`) its syscalls are intercepted. CPU, memory and a time-to-live are capped |
| **Addressable apps** | `expose_port` publishes a process at `http://<name>.<sandbox_id>.<domain>`. Several apps run side by side, each on its own host, so UIs with absolute asset paths and WebSockets work unchanged |
| **Auth per request** | `Authorization: Bearer <token>`, `?token=` (which sets a cookie) or the cookie. Only `/docs`, `/redoc` and `/openapi.json` are public, GET only |
| **Durable home** | a per-user volume mounted as the home directory: a PersistentVolumeClaim, or an S3 bucket prefix synced by rclone with **short-lived credentials scoped to that user's prefix** |
| **Cleanup** | a background reaper deletes expired runtimes and sweeps orphaned services, ingresses and secrets |
## Quick start (laptop)
Prerequisites: Docker, minikube, kubectl, `make`. About 6 GB of RAM for the cluster.
```bash
./scripts/local-up.sh # minikube (containerd) + ingress + gVisor + both images + deploy
minikube tunnel # separate terminal; binds port 80 so *.sandbox.localhost reaches the cluster
curl -i http://mcp.sandbox.localhost/healthz # HTTP 200
```
Prefer to see each step? The script is just this:
```bash
minikube start --driver=docker --container-runtime=containerd --cpus=4 --memory=6144
minikube addons enable ingress
minikube addons enable gvisor # optional isolation layer
make build build-sandbox # docker build + load into minikube
make deploy-local # namespace, RBAC, server, ingress
```
No gVisor? Run `./scripts/local-up.sh --no-gvisor`, or set `RUNTIME_CLASS=` (empty) on the deployment to use the
cluster default runtime.
The full-stack guide, including the agent and the UI, is in the `workbench` repository:
`docs/getting-started.md`.
## Talking to it
The server speaks MCP over Streamable HTTP at `/mcp`. Identity is a header on the connection, not an argument,
so a model can never pick whose home it is touching:
| Header | Meaning |
|---|---|
| `X-User-Id` | the tenant. Its home volume (`user-<id>-pvc` or `users/<id>/` in the bucket) is mounted into every runtime it creates |
| `X-Session-Id` | optional, recorded on the runtime |
## Tool reference
| Tool | Parameters | What it does |
|---|---|---|
| `create_sandbox` | `ttl_minutes`, `cpu_limit`, `memory_limit` | Starts a runtime with the user's home mounted. Returns `sandbox_id`, `url`, `token` |
| `run_command` | `sandbox_id`, `command`, `workdir`, `timeout_seconds` | Runs a bash command; returns stdout, stderr and the return code |
| `start_background` | `sandbox_id`, `command`, `log_file`, `workdir` | Starts a long-running process detached from the exec session; returns its pid |
| `expose_port` | `sandbox_id`, `internal_port`, `name` | Publishes a port at `http://<name>.<sandbox_id>.<domain>`. Ports 8080 and 2019 are reserved |
| `list_apps` / `unexpose_port` | `sandbox_id` [, `name`] | List or remove published apps |
| `write_file` / `read_file` | `sandbox_id`, `path`, `content` / `max_bytes`, `encoding` | Text or base64, size-capped (5 MiB by default), confined to the home directory |
| `list_files` / `delete_file` | `sandbox_id`, `path` [, `recursive`] | List or delete |
| `get_sandbox_access` | `sandbox_id` | The token and apps, for **trusted gateways only** (not for models) |
| `get_sandbox_status` | `sandbox_id` | Pod phase and metadata, with the token redacted |
| `destroy_sandbox` | `sandbox_id` | Deletes the pod, service and ingress. The user's home is kept |
## Configuration
Environment variables (a `.env` is read too).
| Variable | Default | Meaning |
|---|---|---|
| `NAMESPACE` | `sandbox-system` | where runtimes and the server live |
| `BASE_DOMAIN` | `sandbox.localhost` | apps are served at `<name>.<sandbox_id>.<BASE_DOMAIN>` |
| `RUNTIME_CLASS` | `gvisor` | pod runtime class; empty means the cluster default |
| `SANDBOX_IMAGE` | `sandbox-base:latest` | image every runtime starts from |
| `STORAGE_BACKEND` | `pvc` | `pvc` or `s3` |
| `DEFAULT_STORAGE_SIZE`, `DEFAULT_STORAGE_CLASS` | `10Gi`, `standard` | for the PVC backend |
| `DEFAULT_TTL_MINUTES` | `60` | runtime lifetime |
| `DEFAULT_CPU_LIMIT`, `DEFAULT_MEMORY_LIMIT` | `2`, `4Gi` | per-runtime caps |
| `MAX_FILE_BYTES` | `5242880` | `write_file` / `read_file` cap |
| `REAPER_INTERVAL_SECONDS` | `60` | how often expired runtimes are swept |
| `S3_ENDPOINT`, `S3_BUCKET`, `S3_REGION`, `S3_PROVIDER` | | S3 backend: where the bucket is |
| `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` | | admin credentials, used only by this server |
| `S3_SYNC_INTERVAL_SECONDS` | `5` | how often a runtime syncs its home |
## Home storage
| Backend | What it is |
|---|---|
| `pvc` (default) | one PersistentVolumeClaim per user, mounted directly |
| `s3` | a bucket prefix per user, kept in sync with the runtime's home by an rclone sidecar |
With `s3`, a runtime **never receives the admin credentials**. For each runtime the server mints short-lived
(STS) credentials whose policy is limited to `users/<user_id>/*`, so a runtime cannot read another user's
prefix. Two runtimes for the same user sync into the same prefix: newer files win and nothing is deleted when the
sync has to fall back to a merge. An S3 example is in `deploy/k8s/examples/` (RustFS or MinIO work).
## Repository layout
```
src/sandbox_mcp/
server.py, app.py FastMCP server, lifespan, reaper startup
config.py settings
tools/ the MCP tools
k8s/ pods, services, ingresses, exec, annotations
caddy/ renders the per-runtime gateway config
routing.py route table (kept in a pod annotation) and hot reload
storage/ pvc and s3 adapters (scoped credentials, sync sidecar)
reaper.py TTL cleanup
docker/ Dockerfile.mcp (server), Dockerfile.sandbox-base (the runtime image)
deploy/k8s/ namespace, RBAC, deployment, ingress; examples/ for S3
scripts/local-up.sh laptop bootstrap
tests/ unit tests (no cluster)
```
## Tests
```bash
uv sync --extra dev
uv run pytest -q # unit tests, no cluster needed
```
## Security notes
- The runtime image installs packages from the internet on demand and runtimes have open egress by default.
Add a `NetworkPolicy` for anything beyond a laptop.
- The sandbox token is returned by `create_sandbox`. Gateways should keep it server-side and strip it from
anything a model reads.
- This is a spike. Read the code and the RBAC before pointing it at a real cluster.
## License
Apache-2.0. See [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues