Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Tests

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

permissions:
contents: read

jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.13"
- name: Install the image's requirements
run: |
sudo apt-get install -y libcairo2-dev pkg-config
pip install --require-hashes -r requirements.txt
- name: Lint
run: |
pip install ruff==0.15.6
ruff check --select E9,F,B --ignore B008 server.py tests examples
sudo apt-get install -y shellcheck
shellcheck -S warning install.sh entrypoint.sh test-e2e.sh cleanup.sh
bash -n install.sh entrypoint.sh test-e2e.sh cleanup.sh test-build.sh
- name: Unit tests
run: python -m unittest discover -s tests -v
38 changes: 38 additions & 0 deletions AUTHENTICATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Authentication and network boundaries

Both the MCP and the REST routes sit behind a bearer credential. The installer
generates it once into `~/.coderunner/api-token` (mode 0600), or saves the
`CODERUNNER_API_TOKEN` you export; it must be at least 32 bytes with no
whitespace. Every request except `GET/HEAD /health` requires the exact bearer
credential. Missing or invalid server configuration returns 503; missing,
duplicated, or incorrect client credentials return 401. Host and Origin
validation still applies. Authentication does not replace it.

Docker Compose publishes only `127.0.0.1:8222`. When invoking Docker directly,
use `-p 127.0.0.1:8222:8222 -e CODERUNNER_API_TOKEN`. The process inside the guest
still listens on `0.0.0.0:8222`; do not forward this endpoint to the internet.
The Mac installer passes the credential to the container by environment
variable name, checks anonymous rejection, and verifies an authenticated
session-list request before reporting readiness. It does not rotate an existing
container's environment: to change the token, delete the container and run the
installer again.

This is an API credential, not a multi-user authorization system. Anyone with
it can execute arbitrary code, access the guest's files and network, and control
the browser. A task that can read the server process environment inside the
same guest may recover the credential. Keep the guest, mounted directories,
and approved code inside the same trust boundary. Jupyter sessions have separate
interpreter state but share a guest, filesystem and network; they are not
separate VMs or security sandboxes. HTTP is unencrypted; the loopback publication
and host/guest trust assumptions matter. Local malware with the same OS access
is outside this boundary.

The named-session execution path does not automatically retry a command after
a kernel failure. Clients must also avoid retrying uncertain commands. The
unnamed legacy execution API retains its retry behavior and should not be used
for consequential actions that require at-most-once dispatch.

Images published before this change ignore the token; the installer refuses
them. `tests/test_auth.py` covers HTTP, WebSocket, weak or missing
configuration, duplicated credentials and rotation; `test-e2e.sh` checks a
running sandbox.
15 changes: 10 additions & 5 deletions DOCKER_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,19 @@ The Docker build workflow runs on:

### Quick Start with Docker

Export a `CODERUNNER_API_TOKEN` (at least 32 bytes, no whitespace) before starting the container, and send it from every client. Images published before authentication ignore the token: build one from this checkout until a new image is published. See [authentication boundaries](AUTHENTICATION.md).

```bash
# Run CodeRunner with Docker
docker run -d \
--name coderunner \
-p 8222:8222 \
-p 127.0.0.1:8222:8222 \
-e CODERUNNER_API_TOKEN \
-v ./uploads:/app/uploads \
instavm/coderunner:docker-latest

# Access MCP server
curl http://localhost:8222/mcp
curl -H "Authorization: Bearer $CODERUNNER_API_TOKEN" http://127.0.0.1:8222/mcp
```

### Docker Compose (Recommended)
Expand All @@ -60,6 +63,7 @@ docker compose down

### Environment Variables

- `CODERUNNER_API_TOKEN`: Required bearer credential; keep it out of source control.
- `FASTMCP_HOST`: Server host (default: 0.0.0.0)
- `FASTMCP_PORT`: Server port (default: 8222)

Expand All @@ -81,7 +85,8 @@ docker compose down
"command": "docker",
"args": [
"run", "--rm", "-i",
"-p", "8222:8222",
"-p", "127.0.0.1:8222:8222",
"-e", "CODERUNNER_API_TOKEN",
"-v", "./uploads:/app/uploads",
"instavm/coderunner:docker-latest"
]
Expand Down Expand Up @@ -112,7 +117,7 @@ docker buildx build --platform linux/amd64,linux/arm64 -t coderunner-multiarch .

### Common Issues

1. **Port conflicts**: Change port mapping `-p 8223:8222`
1. **Port conflicts**: Change port mapping `-p 127.0.0.1:8223:8222`
2. **Volume permissions**: Ensure `./uploads` directory exists and is writable
3. **Memory issues**: Add memory limits `--memory 4g`

Expand All @@ -126,5 +131,5 @@ docker ps
docker logs coderunner

# Test MCP endpoint
curl -f http://localhost:8222/mcp || echo "MCP not ready"
curl -f -H "Authorization: Bearer $CODERUNNER_API_TOKEN" http://127.0.0.1:8222/mcp || echo "MCP not ready"
```
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
FROM python:3.13.3@sha256:0bc836167214f98aca9c9bca7b4c6dc2c2a77f4a29d5029e6561a14706335102

# Set environment variables
ENV PYTHONDONTWRITEBYTECODE 1

Check warning on line 9 in Dockerfile

View workflow job for this annotation

GitHub Actions / docker-build

Legacy key/value format with whitespace separator should not be used

LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format More info: https://docs.docker.com/go/dockerfile/rule/legacy-key-value-format/
ENV PYTHONUNBUFFERED 1

Check warning on line 10 in Dockerfile

View workflow job for this annotation

GitHub Actions / docker-build

Legacy key/value format with whitespace separator should not be used

LegacyKeyValueFormat: "ENV key=value" should be used instead of legacy "ENV key value" format More info: https://docs.docker.com/go/dockerfile/rule/legacy-key-value-format/
ENV DEBIAN_FRONTEND=noninteractive

# Set working directory
Expand Down Expand Up @@ -71,6 +71,7 @@

# Copy the application code (server.py)
COPY ./server.py /app/server.py
COPY ./auth.py /app/auth.py

# Create application/jupyter directories
RUN mkdir -p /app/uploads /app/jupyter_runtime
Expand Down
64 changes: 55 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,23 @@

CodeRunner helps you sandbox your AI agents and its actions inside a sandbox.

**Key use case:** You can run multiple Claude Code or AI agents in our sandbox without any fear of data loss and exfilteration.
**Key use case:** Run AI agent code in a sandbox with explicitly chosen host mounts and network access. Isolation reduces risk; it does not prevent all data loss or exfiltration.

For cloud managed VMs for agents, we have launched - [InstaVM - Instant computers for AI agents](https://instavm.io)

## Quick Start

**Prerequisites:** Mac with macOS and Apple Silicon (M1/M2/M3/M4), Python 3.10+

### Credentials

The installer generates the sandbox token in `~/.coderunner/api-token` (or saves
the `CODERUNNER_API_TOKEN` you exported). Clients send it as
`Authorization: Bearer <token>`; `cat ~/.coderunner/api-token` prints it. Only
`GET /health` is public, and a missing or weak token fails closed. The Claude
Desktop proxy and the examples read the token from that file when the variable
is not set. See [authentication and network boundaries](AUTHENTICATION.md).

```bash
git clone https://github.com/instavm/coderunner.git
cd coderunner
Expand All @@ -32,7 +41,8 @@ Stop the sandbox when you are done:
container stop coderunner
```

Resume the same sandbox later, preserving uploads, kernels, and installed packages:
Resume the same sandbox later, preserving its filesystem. Python kernel state is
volatile and does not survive a VM or server restart:

```bash
container start coderunner
Expand All @@ -44,6 +54,24 @@ To start over with a clean sandbox, delete the container and run the installer a
container delete coderunner && ./install.sh
```

### Resource limits and workspace mounts

CPU and memory limits can be set with installer options or the `CODERUNNER_CPUS` and `CODERUNNER_MEMORY` environment variables:

```bash
./install.sh --cpus 4 --memory 8g
```

Additional host directories must be mounted under `/workspace`. They are read-only by default; add `:rw` only when the sandbox needs to write to the host:

```bash
./install.sh \
--mount "$HOME/projects/api:/workspace/api" \
--mount "$HOME/projects/output:/workspace/output:rw"
```

Extra mounts default to none. Resource and mount settings are fixed when the container is created; delete the container before changing them.

### Disable outbound network access

By default, code running in the sandbox has unrestricted network access. To run it on a host-only network with no internet access:
Expand All @@ -55,6 +83,14 @@ CODERUNNER_NETWORK=none ./install.sh
In this mode, the MCP server is available at `http://127.0.0.1:8222/mcp`. The setting is fixed when the container is created; the installer refuses to resume a container with a different network mode.


### With Claude Code

Claude Code runs commands on your machine, as you, with permission prompts as the only boundary. Pointing it at
CodeRunner's tools instead runs the code it writes in the VM: it can only touch the folders you mounted under
`/workspace`, the network you allowed, and the sandbox's own filesystem, whatever it was told to do. Use the
sandbox for code that handles untrusted input or that you would not run by hand, and for several agents that
must not share state (named sessions). It does not protect anything you mount read-write.

## Run Claude Code inside a Sandbox

`./install.sh` (if not already done)
Expand Down Expand Up @@ -116,7 +152,12 @@ pip install -r examples/requirements.txt
- Restart Claude Desktop

4. **Start using CodeRunner in Claude:**
You can now ask Claude to execute code, and it will run safely in the sandbox!
Code Claude writes now runs in the sandbox.

If the tool shows as "Disabled" in the chat's tool menu although the server is listed as running: quit Claude
Desktop fully (Cmd-Q) and open it again, make sure `command` is the venv python the installer printed (it
has `fastmcp`), and read `~/Library/Logs/Claude/mcp-server-coderunner.log` for the proxy's error. The proxy
needs the sandbox running (`container list`) and the token file `~/.coderunner/api-token`.
</details>

#### 2. Claude Code CLI
Expand All @@ -127,19 +168,22 @@ pip install -r examples/requirements.txt
**Quick Start:**

```bash
# 1. Install and start CodeRunner (one-time setup)
# 1. Install and start CodeRunner (one-time setup; run as yourself, it asks for sudo when it needs it)
git clone https://github.com/instavm/coderunner.git
cd coderunner
sudo ./install.sh
./install.sh

# 2. Install the Claude Code plugin
claude plugin marketplace add https://github.com/instavm/coderunner-plugin
claude plugin install instavm-coderunner
# 2. Add the sandbox with its token (the installer prints this command)
claude mcp add --transport http coderunner http://coderunner.local:8222/mcp \
--header "Authorization: Bearer $(cat ~/.coderunner/api-token)"

# 3. Reconnect to MCP servers
/mcp
```

The Claude Code plugin (`claude plugin marketplace add https://github.com/instavm/coderunner-plugin`) predates
the token and gets 401 until it sends the same header; use the command above until the plugin is updated.

**Installation Steps:**

1. Navigate to Plugin Marketplace:
Expand Down Expand Up @@ -186,6 +230,7 @@ Create or edit `~/.config/opencode/opencode.json`:
"coderunner": {
"type": "remote",
"url": "http://coderunner.local:8222/mcp",
"headers": { "Authorization": "Bearer <contents of ~/.coderunner/api-token>" },
"enabled": true
}
}
Expand Down Expand Up @@ -231,7 +276,8 @@ After saving the configuration:
"selectedAuthType": "oauth-personal",
"mcpServers": {
"coderunner": {
"httpUrl": "http://coderunner.local:8222/mcp"
"httpUrl": "http://coderunner.local:8222/mcp",
"headers": { "Authorization": "Bearer <contents of ~/.coderunner/api-token>" }
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion SKILLS-README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Anthropic recently announced [Skills for Claude](https://www.anthropic.com/news/
git clone https://github.com/instavm/coderunner.git
cd coderunner
chmod +x install.sh
sudo ./install.sh
./install.sh
```

Installation takes ~2 minutes. The MCP server will be available at `http://coderunner.local:8222/mcp`
Expand Down
40 changes: 40 additions & 0 deletions auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Credential boundary for the MCP and REST interfaces.

Host/Origin validation is complementary; a chosen Host header is not identity.
The credential is supplied by the operator. This module never generates one.
"""

import hmac
import os

from starlette.responses import JSONResponse


class BearerAuthentication:
def __init__(self, app, token_provider=None):
self.app = app
self.token_provider = token_provider or (lambda: os.getenv("CODERUNNER_API_TOKEN", ""))

async def __call__(self, scope, receive, send):
if scope["type"] not in {"http", "websocket"}:
return await self.app(scope, receive, send)
if scope["type"] == "http" and scope["path"] == "/health" and scope["method"] in {"GET", "HEAD"}:
return await self.app(scope, receive, send)
expected = self.token_provider()
configured = len(expected.encode()) >= 32 and not any(c.isspace() for c in expected)
headers = [v for k, v in scope["headers"] if k.lower() == b"authorization"]
supplied = headers[0].split(b" ", 1) if len(headers) == 1 else []
authorized = (
configured and len(supplied) == 2 and supplied[0].lower() == b"bearer"
and hmac.compare_digest(supplied[1], expected.encode())
)
if not authorized:
if scope["type"] == "websocket":
return await send({"type": "websocket.close", "code": 1008})
response = JSONResponse(
{"error": "Authentication required" if configured else "Server authentication is not configured"},
status_code=401 if configured else 503,
headers={"WWW-Authenticate": "Bearer", "Cache-Control": "no-store"},
)
return await response(scope, receive, send)
return await self.app(scope, receive, send)
1 change: 1 addition & 0 deletions cleanup.sh
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#!/bin/bash
sudo pkill -f container


Expand Down
5 changes: 3 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ services:
image: instavm/coderunner:docker-latest
container_name: coderunner
ports:
- "8222:8222"
- "127.0.0.1:8222:8222"
volumes:
- ./uploads:/app/uploads
environment:
- FASTMCP_HOST=0.0.0.0
- FASTMCP_PORT=8222
- CODERUNNER_API_TOKEN=${CODERUNNER_API_TOKEN:?Supply an API token of at least 32 bytes}
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8222/health"]
Expand All @@ -21,4 +22,4 @@ services:

volumes:
uploads:
driver: local
driver: local
21 changes: 20 additions & 1 deletion examples/claude_desktop/mcpproxy.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@

from fastmcp import FastMCP
import os
import pathlib
import socket
import sys


def api_token() -> str:
"""The sandbox token: CODERUNNER_API_TOKEN, else the file the installer wrote."""
token = os.environ.get("CODERUNNER_API_TOKEN", "").strip()
if not token:
try:
token = pathlib.Path(os.environ.get("CODERUNNER_HOME", "~/.coderunner")).expanduser() \
.joinpath("api-token").read_text().strip()
except OSError:
pass
if not token:
print("No sandbox token: run ./install.sh, or export CODERUNNER_API_TOKEN.", file=sys.stderr)
sys.exit(1)
return token

from fastmcp import FastMCP


def resolve_with_system_dns(hostname):
try:
return socket.gethostbyname(hostname)
Expand All @@ -27,6 +45,7 @@ def resolve_with_system_dns(hostname):
"mcpServers": {
"default": { # For single server configs, 'default' is commonly used
"url": f"http://{address}:8222/mcp",
"headers": {"Authorization": "Bearer " + api_token()},
"transport": "http"
}
}
Expand Down
Loading
Loading