diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..498f95f --- /dev/null +++ b/.github/workflows/tests.yml @@ -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 diff --git a/AUTHENTICATION.md b/AUTHENTICATION.md new file mode 100644 index 0000000..2043b22 --- /dev/null +++ b/AUTHENTICATION.md @@ -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. diff --git a/DOCKER_SETUP.md b/DOCKER_SETUP.md index 3b2a370..451dd5c 100644 --- a/DOCKER_SETUP.md +++ b/DOCKER_SETUP.md @@ -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) @@ -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) @@ -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" ] @@ -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` @@ -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" ``` \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index a507117..7f9245e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -71,6 +71,7 @@ RUN python -m bash_kernel.install # 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 diff --git a/README.md b/README.md index 29ceed0..d664c64 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ 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) @@ -17,6 +17,15 @@ For cloud managed VMs for agents, we have launched - [InstaVM - Instant computer **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 `; `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 @@ -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 @@ -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: @@ -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) @@ -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`. #### 2. Claude Code CLI @@ -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: @@ -186,6 +230,7 @@ Create or edit `~/.config/opencode/opencode.json`: "coderunner": { "type": "remote", "url": "http://coderunner.local:8222/mcp", + "headers": { "Authorization": "Bearer " }, "enabled": true } } @@ -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 " } } } } diff --git a/SKILLS-README.md b/SKILLS-README.md index 258d542..647d8bd 100644 --- a/SKILLS-README.md +++ b/SKILLS-README.md @@ -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` diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..249a566 --- /dev/null +++ b/auth.py @@ -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) diff --git a/cleanup.sh b/cleanup.sh index 30ebb00..3f4aea0 100755 --- a/cleanup.sh +++ b/cleanup.sh @@ -1,3 +1,4 @@ +#!/bin/bash sudo pkill -f container diff --git a/docker-compose.yml b/docker-compose.yml index 94fdaa2..44817f8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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"] @@ -21,4 +22,4 @@ services: volumes: uploads: - driver: local \ No newline at end of file + driver: local diff --git a/examples/claude_desktop/mcpproxy.py b/examples/claude_desktop/mcpproxy.py index 52bbe76..258dce8 100644 --- a/examples/claude_desktop/mcpproxy.py +++ b/examples/claude_desktop/mcpproxy.py @@ -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) @@ -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" } } diff --git a/examples/openai_agents/openai_client.py b/examples/openai_agents/openai_client.py index b5897d9..59cf8d8 100644 --- a/examples/openai_agents/openai_client.py +++ b/examples/openai_agents/openai_client.py @@ -1,12 +1,28 @@ import asyncio +import os +import pathlib import socket - +import sys from agents import Agent, Runner, gen_trace_id, trace from agents.mcp import MCPServer, MCPServerStreamableHttp from agents.model_settings import ModelSettings +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 + async def run(mcp_server: MCPServer): agent = Agent( @@ -41,6 +57,7 @@ async def main(): name="SSE Python Server", params={ "url": f"http://{address}:8222/mcp", + "headers": {"Authorization": "Bearer " + api_token()}, "sse_read_timeout": 60, "timeout": 60, }, diff --git a/install.sh b/install.sh index 4689cab..7316b86 100755 --- a/install.sh +++ b/install.sh @@ -1,6 +1,37 @@ #!/bin/bash set -u +# Run as yourself: under sudo, HOME is root's, so the token, the venv and the assets would land in /var/root and +# the clients run as you would never find them. The steps that need root ask for sudo themselves. +if [ "$(id -u)" = "0" ]; then + echo "❌ Run ./install.sh as your own user, not with sudo." >&2 + exit 1 +fi +CODERUNNER_HOME="${CODERUNNER_HOME:-$HOME/.coderunner}" + +# The sandbox credential: created once and kept in a 0600 file for the clients on this Mac. An exported +# CODERUNNER_API_TOKEN takes precedence and is saved. Checked before any download, DNS change or restart. +ensure_token() { + local token_file="$CODERUNNER_HOME/api-token" token + mkdir -p "$CODERUNNER_HOME" + if [ -n "${CODERUNNER_API_TOKEN:-}" ]; then + token="$CODERUNNER_API_TOKEN" + elif [ -s "$token_file" ]; then + token=$(tr -d '[:space:]' < "$token_file") + else + token=$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))' 2>/dev/null \ + || openssl rand -base64 48 | tr '+/' '-_' | tr -d '=\n') + fi + if [ "${#token}" -lt 32 ] || [[ "$token" =~ [[:space:]] ]]; then + echo "❌ CODERUNNER_API_TOKEN must be at least 32 characters without whitespace." >&2 + exit 1 + fi + (umask 077 && printf '%s\n' "$token" > "$token_file") + CODERUNNER_API_TOKEN="$token" + export CODERUNNER_API_TOKEN +} +ensure_token + CONTAINER_VERSION="1.2.2" CONTAINER_INSTALLER="container-${CONTAINER_VERSION}-installer-signed.pkg" download_url="https://github.com/apple/container/releases/download/${CONTAINER_VERSION}/${CONTAINER_INSTALLER}" @@ -19,6 +50,120 @@ install_container_tool() { rm -f "$CONTAINER_INSTALLER" } +CPUS="${CODERUNNER_CPUS:-8}" +MEMORY="${CODERUNNER_MEMORY:-4g}" +MOUNT_SPECS=() +VOLUME_ARGS=() + +usage() { + cat <<'EOF' +Usage: ./install.sh [options] + +Options: + --cpus N CPU count (default: 8) + --memory SIZE Memory limit, e.g. 4g or 8192m (default: 4g) + --mount HOST:TARGET[:ro|rw] + Mount a host directory under /workspace. + Mounts are read-only unless :rw is specified. + -h, --help Show this help +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --cpus) + [ "$#" -ge 2 ] || { echo "Error: --cpus requires a value." >&2; exit 2; } + CPUS="$2" + shift 2 + ;; + --memory) + [ "$#" -ge 2 ] || { echo "Error: --memory requires a value." >&2; exit 2; } + MEMORY="$2" + shift 2 + ;; + --mount) + [ "$#" -ge 2 ] || { echo "Error: --mount requires a value." >&2; exit 2; } + MOUNT_SPECS+=("$2") + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Error: unknown option '$1'." >&2 + usage >&2 + exit 2 + ;; + esac +done + +if ! [[ "$CPUS" =~ ^[1-9][0-9]*$ ]]; then + echo "Error: CPU count must be a positive integer." >&2 + exit 2 +fi +if ! [[ "$MEMORY" =~ ^[1-9][0-9]*([KkMmGgTtPp][Bb]?)?$ ]]; then + echo "Error: memory must be a positive size such as 4g or 8192m." >&2 + exit 2 +fi +MEMORY=$(printf '%s' "$MEMORY" | tr '[:upper:]' '[:lower:]') +MEMORY=${MEMORY%b} + +NORMALIZED_MOUNTS=() +for spec in ${MOUNT_SPECS[@]+"${MOUNT_SPECS[@]}"}; do + source_path=${spec%%:*} + remainder=${spec#*:} + if [ "$remainder" = "$spec" ] || [ -z "$source_path" ] || [ -z "$remainder" ]; then + echo "Error: mount must use HOST:TARGET[:ro|rw]." >&2 + exit 2 + fi + target_path=${remainder%%:*} + if [ "$target_path" = "$remainder" ]; then + mode="ro" + else + mode=${remainder#*:} + if [[ "$mode" == *:* ]]; then + echo "Error: mount must use HOST:TARGET[:ro|rw]." >&2 + exit 2 + fi + fi + if [ "$mode" != "ro" ] && [ "$mode" != "rw" ]; then + echo "Error: mount mode must be 'ro' or 'rw'." >&2 + exit 2 + fi + if [ ! -d "$source_path" ]; then + echo "Error: mount source is not a directory: $source_path" >&2 + exit 2 + fi + source_path=$(cd "$source_path" && pwd -P) + case "$target_path" in + /workspace|/workspace/*) ;; + *) + echo "Error: mount targets must be /workspace or a path below it." >&2 + exit 2 + ;; + esac + if [[ "$target_path" == *"/../"* ]] || [[ "$target_path" == */.. ]] || [[ "$target_path" == *"//"* ]]; then + echo "Error: mount target contains an invalid path segment." >&2 + exit 2 + fi + for existing in ${NORMALIZED_MOUNTS[@]+"${NORMALIZED_MOUNTS[@]}"}; do + existing_target=${existing#*:} + existing_target=${existing_target%:*} + if [ "$existing_target" = "$target_path" ]; then + echo "Error: duplicate mount target: $target_path" >&2 + exit 2 + fi + done + NORMALIZED_MOUNTS+=("$source_path:$target_path:$mode") + if [ "$mode" = "ro" ]; then + VOLUME_ARGS+=(--volume "$source_path:$target_path:ro") + else + # Apple's --volume syntax represents writable mounts by omitting :ro. + VOLUME_ARGS+=(--volume "$source_path:$target_path") + fi +done + # Function to get current macOS version get_macos_version() { sw_vers -productVersion | awk -F. '{print $1 "." $2}' @@ -39,14 +184,16 @@ else echo "✅ macOS system detected." fi -# Check if container is installed and display its version +# Install Apple 'container' when it is missing or older than the version this installer was tested with; a +# newer one is kept (replacing 1.5 with 1.2 would be a downgrade, with sudo). +version_at_least() { + [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -n1)" = "$2" ] +} if command -v container &> /dev/null then - echo "Apple 'container' tool detected. Current version:" - container --version current_version=$(container --version | awk '{print $4}') - echo $current_version - if [ "$current_version" != "$CONTAINER_VERSION" ]; then + echo "Apple 'container' $current_version detected." + if ! version_at_least "$current_version" "$CONTAINER_VERSION"; then echo "Updating Apple 'container' to version $CONTAINER_VERSION..." install_container_tool fi @@ -183,11 +330,29 @@ esac # Wait until the MCP server inside the container answers over HTTP. wait_for_server() { echo "Waiting for the MCP server to become ready (this can take a minute)..." - local i code - for i in $(seq 1 60); do + local code auth_code unauth_code + for _ in $(seq 1 60); do code=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 2 "http://$MCP_HOST:8222/health" 2>/dev/null) if [ "$code" = "200" ]; then - echo "✅ Setup complete. MCP server is available at http://$MCP_HOST:8222/mcp" + unauth_code=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 2 \ + "http://$MCP_HOST:8222/v1/sessions/session") + # The header comes from a file descriptor, so the token never appears in a process list. + auth_code=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 2 \ + -H @<(printf 'Authorization: Bearer %s\n' "$CODERUNNER_API_TOKEN") \ + "http://$MCP_HOST:8222/v1/sessions/session") + if [ "$auth_code" != "200" ] || [ "$unauth_code" != "401" ]; then + # Left running, an image that ignores the token is an open code-execution service. + container stop coderunner 2>/dev/null || true + echo "❌ The server must reject anonymous requests and accept the configured credential;" + echo " the sandbox has been stopped. An image built before authentication ignores the" + echo " token: recreate the sandbox with container delete coderunner && ./install.sh" + return 1 + fi + echo "✅ Setup complete. MCP server: http://$MCP_HOST:8222/mcp" + echo " Every client sends the token in $CODERUNNER_HOME/api-token. For Claude Code:" + echo " claude mcp add --transport http coderunner http://$MCP_HOST:8222/mcp \\" + echo " --header \"Authorization: Bearer \$(cat $CODERUNNER_HOME/api-token)\"" + echo " Other clients: see README.md (Claude Desktop uses the proxy, which reads the file itself)." return 0 fi sleep 2 @@ -199,6 +364,26 @@ wait_for_server() { return 1 } +CONFIG_FILE="$HOME/.coderunner/container-config" +desired_config="cpus=$CPUS"$'\n'"memory=$MEMORY" +if [ -n "${NORMALIZED_MOUNTS[*]-}" ]; then + sorted_mounts=$(printf '%s\n' ${NORMALIZED_MOUNTS[@]+"${NORMALIZED_MOUNTS[@]}"} | LC_ALL=C sort) + desired_config="${desired_config}"$'\n'"$(printf '%s\n' "$sorted_mounts" | sed 's/^/mount=/')" +fi +if container inspect coderunner &>/dev/null; then + if [ -f "$CONFIG_FILE" ]; then + if [ "$(cat "$CONFIG_FILE")" != "$desired_config" ]; then + echo "❌ Existing container was created with different resource or mount settings." + echo " Recreate it with: container delete coderunner && ./install.sh [options]" + exit 1 + fi + elif [ "$desired_config" != $'cpus=8\nmemory=4g' ]; then + echo "❌ Existing container predates configurable resources and mounts." + echo " Recreate it with: container delete coderunner && ./install.sh [options]" + exit 1 + fi +fi + # Stop any existing coderunner container echo "Stopping any existing coderunner container..." container stop coderunner 2>/dev/null || true @@ -222,6 +407,11 @@ print(networks[0]["network"] if networks else "default") exit $? fi fi +if container inspect coderunner &>/dev/null; then + echo "❌ Existing coderunner container could not be started." + echo " Check its logs, or recreate it with: container delete coderunner && ./install.sh [options]" + exit 1 +fi echo "Pulling the latest image: instavm/coderunner" if ! container image pull instavm/coderunner; then @@ -230,16 +420,19 @@ if ! container image pull instavm/coderunner; then fi # Run the command to start the sandbox container -echo "Running: container run --volume \"$ASSETS_SRC/skills/user:/app/uploads/skills/user\" --volume \"$ASSETS_SRC/outputs:/app/uploads/outputs\" --name coderunner --detach --cpus 8 --memory 4g instavm/coderunner" +echo "Starting coderunner with $CPUS CPUs and $MEMORY memory..." if container run \ --volume "$ASSETS_SRC/skills/user:/app/uploads/skills/user" \ --volume "$ASSETS_SRC/outputs:/app/uploads/outputs" \ + ${VOLUME_ARGS[@]+"${VOLUME_ARGS[@]}"} \ --name coderunner \ --detach \ - --cpus 8 \ - --memory 4g \ + --cpus "$CPUS" \ + --memory "$MEMORY" \ + --env CODERUNNER_API_TOKEN \ ${NETWORK_ARGS[@]+"${NETWORK_ARGS[@]}"} \ instavm/coderunner; then + printf '%s' "$desired_config" > "$CONFIG_FILE" wait_for_server exit $? else diff --git a/server.py b/server.py index e7aba9d..f0ae55d 100644 --- a/server.py +++ b/server.py @@ -1,7 +1,5 @@ # --- IMPORTS --- import asyncio -import base64 -import binascii import json import logging import os @@ -29,6 +27,7 @@ import socket from starlette.requests import Request from starlette.responses import JSONResponse +from auth import BearerAuthentication # --- CONFIGURATION & SETUP --- logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" @@ -74,8 +73,8 @@ RETRY_BACKOFF_BASE = 2 # exponential backoff base # Jupyter connection settings -JUPYTER_WS_URL = "ws://127.0.0.1:8888" -JUPYTER_HTTP_URL = "http://127.0.0.1:8888" +JUPYTER_HTTP_URL = os.environ.get("CODERUNNER_JUPYTER_URL", "http://127.0.0.1:8888").rstrip("/") +JUPYTER_WS_URL = "ws" + JUPYTER_HTTP_URL[len("http"):] # Enhanced WebSocket settings WEBSOCKET_TIMEOUT = 600 # 10 minutes for long operations @@ -84,8 +83,11 @@ # Directory configuration (ensure this matches your Jupyter/Docker setup) # This directory must be accessible by both this script and the Jupyter kernel. -SHARED_DIR = pathlib.Path("/app/uploads") -SHARED_DIR.mkdir(exist_ok=True) +SHARED_DIR = pathlib.Path(os.environ.get("CODERUNNER_SHARED_DIR", "/app/uploads")) +try: + SHARED_DIR.mkdir(parents=True, exist_ok=True) +except OSError as exc: # outside the image (tests): the directory is not needed to import + logger.warning(f"Could not create {SHARED_DIR}: {exc}") KERNEL_ID_FILE_PATH = SHARED_DIR / "python_kernel_id.txt" # Skills directory configuration @@ -100,7 +102,60 @@ def resolve_with_system_dns(hostname): print(f"Error resolving {hostname}: {e}") return None -PLAYWRIGHT_WS_URL =f"ws://127.0.0.1:3000/" +PLAYWRIGHT_WS_URL = "ws://127.0.0.1:3000/" +NAVIGATE_TIMEOUT_MS = 30_000 +MAX_PAGE_TEXT = 200_000 # characters returned from a page + + +def public_url(url: str, addresses: Optional[set[str]] = None) -> str | None: + """Why a URL may not be opened by the browser tool, or None when it may: only http and https, and only + hosts whose every address is globally routable (no loopback, private, link-local, shared 100.64/10, + multicast or reserved address). The browser runs inside the sandbox, where those addresses reach the + Jupyter server, the host and the LAN. `addresses` are the host's resolved addresses when the caller already + has them; otherwise the host is resolved here (synchronously: callers on the event loop use checked_url).""" + import ipaddress + try: + parsed = urlsplit(url) + except ValueError: + return "not a valid URL" + if parsed.scheme not in ("http", "https"): + return "only http and https URLs can be opened" + host = parsed.hostname + if not host: + return "the URL has no host" + if addresses is None: + try: + addresses = resolve_host(host, parsed.port or 80) + except socket.gaierror: + return f"{host} does not resolve" + for address in addresses: + ip = ipaddress.ip_address(address.split("%")[0]) + if not ip.is_global or ip.is_multicast or ip.is_reserved: + return f"{host} is not a public address" + return None + + +def resolve_host(host: str, port: int) -> set[str]: + return {info[4][0] for info in socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)} + + +RESOLVE_TIMEOUT = 10.0 + + +async def checked_url(url: str) -> str | None: + """public_url for the event loop: the DNS lookup runs in a thread and waits at most RESOLVE_TIMEOUT.""" + parsed = urlsplit(url) + if parsed.scheme not in ("http", "https") or not parsed.hostname: + return public_url(url, addresses=set()) + try: + addresses = await asyncio.wait_for(asyncio.to_thread(resolve_host, parsed.hostname, parsed.port or 80), + RESOLVE_TIMEOUT) + except socket.gaierror: + return f"{parsed.hostname} does not resolve" + except asyncio.TimeoutError: + return f"{parsed.hostname} took too long to resolve" + return public_url(url, addresses=addresses) + # --- CUSTOM EXCEPTIONS --- @@ -120,6 +175,10 @@ class KernelTimeoutError(KernelError): """Raised when kernel operation times out""" pass +class KernelConnectError(KernelError): + """Raised when the kernel could not be reached before the code was sent (safe to retry)""" + pass + class SessionConflictError(ValueError): """Raised when a requested session ID already exists""" pass @@ -247,7 +306,7 @@ async def discard_kernel(self, kernel_id: str): async def _get_existing_kernel(self) -> Optional[str]: """Try to get kernel ID from existing file""" try: - async with aiofiles.open(KERNEL_ID_FILE_PATH, mode='r') as f: + async with aiofiles.open(KERNEL_ID_FILE_PATH) as f: kernel_id = (await f.read()).strip() if kernel_id and await self._check_kernel_health(kernel_id): return kernel_id @@ -497,7 +556,9 @@ def create_jupyter_request(code: str) -> tuple[str, str]: # --- ENHANCED EXECUTION WITH RETRY LOGIC --- async def execute_with_retry(command: str, ctx: Context, max_attempts: int = MAX_RETRY_ATTEMPTS) -> str: - """Execute code with retry logic and exponential backoff""" + """Execute code, trying another kernel when one cannot be reached. Code that was sent to a kernel is + never sent again: a connection lost during a run, or a run that timed out, is reported as an error, since + the code may have had side effects (files written, requests made) that a second run would repeat.""" last_error = None for attempt in range(max_attempts): @@ -515,11 +576,14 @@ async def execute_with_retry(command: str, ctx: Context, max_attempts: int = MAX except KernelExecutionError as e: await kernel_pool.release_kernel(kernel_id) return f"Error: {e}" - except Exception as e: + except KernelConnectError as e: await kernel_pool.discard_kernel(kernel_id) raise e + except Exception as e: + await kernel_pool.discard_kernel(kernel_id) + return f"Error: {e} (the code was sent and is not run again; check its effects before retrying)" - except Exception as e: + except (NoKernelAvailableError, KernelConnectError) as e: last_error = e if attempt < max_attempts - 1: backoff_time = RETRY_BACKOFF_BASE ** attempt @@ -536,8 +600,8 @@ async def _execute_on_kernel(kernel_id: str, command: str, ctx: Context) -> str: final_output_lines = [] sent_msg_id = None + sent = False # once the code is sent, a failure must not lead to a second run try: - # Enhanced WebSocket connection with longer timeouts async with websockets.connect( jupyter_ws_url, ping_interval=WEBSOCKET_PING_INTERVAL, @@ -545,6 +609,7 @@ async def _execute_on_kernel(kernel_id: str, command: str, ctx: Context) -> str: close_timeout=10 ) as jupyter_ws: sent_msg_id, jupyter_request_json = create_jupyter_request(command) + sent = True # a send that raises may still have reached the kernel await jupyter_ws.send(jupyter_request_json) logger.info(f"Sent execute_request to kernel {kernel_id} (msg_id: {sent_msg_id})") @@ -614,17 +679,19 @@ async def _execute_on_kernel(kernel_id: str, command: str, ctx: Context) -> str: return "".join(final_output_lines) if final_output_lines else "[Execution successful with no output]" - except websockets.exceptions.ConnectionClosed as e: - error_msg = f"WebSocket connection to kernel {kernel_id} closed unexpectedly: {e}" - logger.error(error_msg) - raise KernelError(error_msg) - except websockets.exceptions.WebSocketException as e: - error_msg = f"WebSocket error with kernel {kernel_id}: {e}" - logger.error(error_msg) - raise KernelError(error_msg) + except (KernelExecutionError, KernelTimeoutError): + raise except Exception as e: - logger.error(f"Unexpected error during execution on kernel {kernel_id}: {e}", exc_info=True) - raise e + if not sent: # nothing reached the kernel: another one can take the code + raise KernelConnectError(f"Could not connect to kernel {kernel_id}: {e}") from e + if isinstance(e, websockets.exceptions.ConnectionClosed): + error_msg = f"WebSocket connection to kernel {kernel_id} closed unexpectedly: {e}" + elif isinstance(e, websockets.exceptions.WebSocketException): + error_msg = f"WebSocket error with kernel {kernel_id}: {e}" + else: + error_msg = f"Unexpected error during execution on kernel {kernel_id}: {e}" + logger.error(error_msg, exc_info=not isinstance(e, websockets.exceptions.WebSocketException)) + raise KernelError(error_msg) from e # --- MCP TOOLS --- @mcp.tool() @@ -686,28 +753,36 @@ async def navigate_and_get_all_visible_text(url: str) -> str: Args: url: The URL of the webpage from which to retrieve text. """ - # This function doesn't have intermediate steps, so it only needs 'return'. + reason = await checked_url(url) + if reason: + return f"Error: {reason}" + + async def only_public(route): + # Redirects, frames and subresources are requests of their own: each is checked like the first URL. + # The browser resolves the name again when it connects, so a name that changes its address between + # the check and the connection is not caught here. + if await checked_url(route.request.url): + await route.abort("blockedbyclient") + else: + await route.continue_() + try: - # Note: 'async with async_playwright() as p:' can be slow. - # For performance, consider managing a single Playwright instance - # outside the tool function if this tool is called frequently. async with async_playwright() as p: browser = await p.chromium.connect(PLAYWRIGHT_WS_URL) - page = await browser.new_page() - await page.goto(url) - - html_content = await page.content() - soup = BeautifulSoup(html_content, 'html.parser') - visible_text = soup.get_text(separator="\n", strip=True) - - await browser.close() - - # The operation is complete, return the final result. - return visible_text - + try: + page = await browser.new_page() + await page.route("**/*", only_public) + await page.goto(url, timeout=NAVIGATE_TIMEOUT_MS, wait_until="domcontentloaded") + html_content = await page.content() + finally: + await browser.close() + soup = BeautifulSoup(html_content, 'html.parser') + visible_text = soup.get_text(separator="\n", strip=True) + if len(visible_text) > MAX_PAGE_TEXT: + visible_text = visible_text[:MAX_PAGE_TEXT] + "\n[... cut after 200,000 characters]" + return visible_text except Exception as e: logger.error(f"Failed to retrieve all visible text: {e}") - # An error occurred, return the final error message. return f"Error: Failed to retrieve all visible text: {str(e)}" @@ -716,7 +791,7 @@ async def navigate_and_get_all_visible_text(url: str) -> str: async def _parse_skill_frontmatter(skill_md_path): try: - async with aiofiles.open(skill_md_path, mode='r') as f: + async with aiofiles.open(skill_md_path) as f: content = await f.read() frontmatter = [] in_frontmatter = False @@ -740,10 +815,16 @@ async def _parse_skill_frontmatter(skill_md_path): return {} +MAX_SKILL_ARCHIVE_BYTES = 200 * 1024 * 1024 # uncompressed + + def _extract_skill_archive(archive_path: pathlib.Path) -> None: destination = USER_SKILLS_DIR.resolve() with zipfile.ZipFile(archive_path, "r") as archive: - for member in archive.infolist(): + members = archive.infolist() + if sum(member.file_size for member in members) > MAX_SKILL_ARCHIVE_BYTES: + raise ValueError("Skill archive is too large when unpacked (200 MB at most)") + for member in members: target = (destination / member.filename).resolve() if not target.is_relative_to(destination): raise ValueError(f"Unsafe path in skill archive: {member.filename}") @@ -778,12 +859,17 @@ async def list_skills() -> str: JSON string with skill names organized by category. """ try: - # Unzip any user-provided skills + # Unzip any user-provided skills. A bad archive is set aside (.zip.bad) so it does not stop the + # listing on every call. if USER_SKILLS_DIR.exists(): for item in USER_SKILLS_DIR.iterdir(): if item.is_file() and item.suffix == '.zip': - _extract_skill_archive(item) - os.remove(item) + try: + _extract_skill_archive(item) + os.remove(item) + except Exception as exc: # unsafe paths, a bad or password-protected zip, disk errors + logger.warning(f"Skill archive {item.name} not unpacked: {exc}") + item.rename(item.with_suffix(".zip.bad")) skills = { "public": [], @@ -810,7 +896,7 @@ async def process_skill_dir(directory, category): skills["public"].sort(key=lambda x: x['name']) skills["user"].sort(key=lambda x: x['name']) - result = f"Available Skills:\n\n" + result = "Available Skills:\n\n" result += f"Public Skills ({len(skills['public'])}):\n" if skills["public"]: for skill in skills["public"]: @@ -825,7 +911,7 @@ async def process_skill_dir(directory, category): else: result += " (none)\n" - result += f"\nUse get_skill_info(skill_name) to read documentation for a specific skill." + result += "\nUse get_skill_info(skill_name) to read documentation for a specific skill." return result @@ -856,7 +942,7 @@ async def _read_skill_file(skill_name: str, filename: str) -> tuple[str, str, st for base_dir, category in ((PUBLIC_SKILLS_DIR, "public"), (USER_SKILLS_DIR, "user")): candidate = (base_dir / skill_name / filename).resolve() if not candidate.is_relative_to(base_dir.resolve()): - return None, None, f"Error: Invalid skill or file name." + return None, None, "Error: Invalid skill or file name." if candidate.is_file(): skill_file_path = candidate skill_type = category @@ -866,7 +952,7 @@ async def _read_skill_file(skill_name: str, filename: str) -> tuple[str, str, st return None, None, f"Error: File '{filename}' not found in skill '{skill_name}'. Use list_skills() to see available skills." # Read the file content - async with aiofiles.open(skill_file_path, mode='r') as f: + async with aiofiles.open(skill_file_path) as f: content = await f.read() # Replace all occurrences of /mnt/user-data with /app/uploads @@ -962,7 +1048,7 @@ def _header_hostname(value: str, *, origin: bool = False) -> Optional[str]: return None if parsed.path or parsed.query or parsed.fragment: return None - parsed.port + _ = parsed.port # raises ValueError for a malformed port return parsed.hostname.lower() if parsed.hostname else None except ValueError: return None @@ -994,8 +1080,17 @@ def _is_allowed(scope) -> bool: async def api_health(request: Request): - """Liveness endpoint used by the installer and container healthchecks.""" - return JSONResponse({"status": "ok"}) + """Liveness for the installer and the container healthcheck: 200 only when the Jupyter server that runs + the kernels answers too, so a sandbox whose kernels cannot start is not reported healthy.""" + try: + async with httpx.AsyncClient(timeout=3.0) as client: + response = await client.get(f"{JUPYTER_HTTP_URL}/api/status") + jupyter = response.status_code == 200 + except httpx.HTTPError: + jupyter = False + body = {"status": "ok" if jupyter else "degraded", "jupyter": jupyter, + "kernels": len(kernel_pool.kernels), "sessions": len(python_sessions.sessions)} + return JSONResponse(body, status_code=200 if jupyter else 503) # Add custom REST API endpoints compatible with instavm SDK client async def api_execute(request: Request): @@ -1242,4 +1337,4 @@ async def api_stop_session(request: Request): app.add_route("/v1/browser/interactions/content", api_browser_extract_content, methods=["POST"]) # Wrap the app last so validation covers every route -app = HostOriginValidator(app) +app = HostOriginValidator(BearerAuthentication(app)) diff --git a/test-e2e.sh b/test-e2e.sh index c0c4c2e..faaa3b9 100755 --- a/test-e2e.sh +++ b/test-e2e.sh @@ -10,6 +10,9 @@ set -u HOST="${1:-coderunner.local}" BASE="http://$HOST:8222" +TOKEN="${CODERUNNER_API_TOKEN:-$(tr -d '[:space:]' < "${CODERUNNER_HOME:-$HOME/.coderunner}/api-token" 2>/dev/null)}" +# Every request below carries the sandbox token. +curl() { command curl -H "Authorization: Bearer $TOKEN" "$@"; } SKILLS_DIR="${SKILLS_DIR:-$HOME/.coderunner/assets/skills/user}" PASS=0 FAIL=0 @@ -45,6 +48,11 @@ check "foreign Origin is rejected" "403" "$code" code=$(curl -s -o /dev/null -w '%{http_code}' -H "Host: evil.example" "$BASE/health") check "foreign Host is rejected" "403" "$code" +# 3b. A wrong token is refused +code=$(command curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/execute" \ + -H "Authorization: Bearer wrong-token" -H "Content-Type: application/json" -d '{"code": "print(1)"}') +check "wrong token is rejected" "401" "$code" + # 4. Code execution via the REST API resp=$(curl -s -X POST "$BASE/execute" -H "Content-Type: application/json" \ -d '{"code": "print(6 * 7)"}') diff --git a/test-sessions.py b/test-sessions.py index 946758b..3883c09 100755 --- a/test-sessions.py +++ b/test-sessions.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import json +import os import sys import time import urllib.error @@ -8,13 +9,14 @@ from concurrent.futures import ThreadPoolExecutor from threading import Lock - BASE_URL = sys.argv[1] if len(sys.argv) > 1 else "http://coderunner.local:8222" def request(path, method="GET", payload=None, headers=None): data = json.dumps(payload).encode() if payload is not None else None - request_headers = {"Content-Type": "application/json", **(headers or {})} + request_headers = {"Content-Type": "application/json", + "Authorization": "Bearer " + os.environ["CODERUNNER_API_TOKEN"], + **(headers or {})} req = urllib.request.Request( f"{BASE_URL}{path}", data=data, diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..5773902 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,57 @@ +import unittest + +import httpx +from starlette.responses import JSONResponse + +from auth import BearerAuthentication + + +class AuthenticationTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.calls = [] + self.token = "fixture-credential-not-for-use-123456789" + + async def downstream(scope, receive, send): + self.calls.append(scope["path"]) + await JSONResponse({"ok": True})(scope, receive, send) + + self.app = BearerAuthentication(downstream, lambda: self.token) + self.client = httpx.AsyncClient(transport=httpx.ASGITransport(app=self.app), base_url="http://localhost") + + async def asyncTearDown(self): + await self.client.aclose() + + async def test_commands_require_exact_bearer_credential(self): + for path in ("/mcp", "/execute", "/v1/sessions/session", "/v1/browser/interactions/navigate"): + for auth in (None, "Basic " + self.token, "Bearer wrong", "Bearer " + self.token + " "): + result = await self.client.post(path, headers={"Authorization": auth} if auth else {}) + self.assertEqual(result.status_code, 401) + self.assertEqual(self.calls, []) + response = await self.client.post("/execute", headers={"Authorization": "Bearer " + self.token}) + self.assertEqual(response.status_code, 200) + self.assertEqual(self.calls, ["/execute"]) + + async def test_missing_or_weak_configuration_fails_closed(self): + for value in ("", "short", " " + "x" * 40): + self.token = value + result = await self.client.post("/execute", headers={"Authorization": "Bearer " + value}) + self.assertEqual(result.status_code, 503) + self.assertEqual(self.calls, []) + self.assertEqual((await self.client.get("/health")).status_code, 200) + self.assertEqual((await self.client.post("/health")).status_code, 503) + + async def test_duplicate_auth_and_rotated_token_are_rejected(self): + previous = self.token + headers = [("Authorization", "Bearer " + previous)] * 2 + self.assertEqual((await self.client.post("/execute", headers=headers)).status_code, 401) + self.token = "replacement-fixture-credential-123456789" + self.assertEqual((await self.client.post("/execute", headers={"Authorization": "Bearer " + previous})).status_code, 401) + + async def test_websocket_cannot_bypass_auth(self): + sent = [] + + async def send(value): + sent.append(value) + + await self.app({"type": "websocket", "path": "/mcp", "headers": []}, None, send) + self.assertEqual(sent, [{"type": "websocket.close", "code": 1008}]) diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..9d5bc96 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,139 @@ +"""Unit tests for the sandbox server: request validation, the browser tool's URL guard, skill archives, session +ids and the health check. They import server.py outside the image (CODERUNNER_SHARED_DIR points at a temporary +folder; the Jupyter URL at a port where nothing listens), with the image's requirements installed.""" + +import asyncio +import os +import pathlib +import shutil +import tempfile +import unittest +import zipfile +from unittest import mock + +import httpx + +ROOT = pathlib.Path(__file__).resolve().parent.parent +SHARED = pathlib.Path(tempfile.mkdtemp(prefix="coderunner-test-")) +os.environ["CODERUNNER_SHARED_DIR"] = str(SHARED) +os.environ["CODERUNNER_JUPYTER_URL"] = "http://127.0.0.1:1" # nothing listens here +os.environ.setdefault("CODERUNNER_API_TOKEN", "fixture-credential-not-for-use-123456789") +import sys # noqa: E402 + +sys.path.insert(0, str(ROOT)) +import server # noqa: E402 + + +class HostOriginTests(unittest.TestCase): + def test_host_header_parsing(self): + hostname = server._header_hostname + self.assertEqual(hostname("coderunner.local:8222"), "coderunner.local") + self.assertEqual(hostname("127.0.0.1"), "127.0.0.1") + self.assertEqual(hostname("Localhost:8222"), "localhost") + self.assertIsNone(hostname("user:pw@localhost")) + self.assertIsNone(hostname("localhost:8222/path")) + self.assertIsNone(hostname("localhost:notaport")) + self.assertEqual(hostname("http://localhost:3000", origin=True), "localhost") + self.assertIsNone(hostname("file://localhost", origin=True)) + self.assertIsNone(hostname("http://evil.example/?x=1", origin=True)) + + def test_foreign_host_or_origin_is_refused(self): + scope = {"type": "http", "headers": [(b"host", b"coderunner.local:8222")]} + self.assertTrue(server.HostOriginValidator._is_allowed(scope)) + scope["headers"] = [(b"host", b"evil.example")] + self.assertFalse(server.HostOriginValidator._is_allowed(scope)) + scope["headers"] = [(b"host", b"localhost:8222"), (b"origin", b"http://evil.example")] + self.assertFalse(server.HostOriginValidator._is_allowed(scope)) + scope["headers"] = [(b"host", b"localhost:8222"), (b"origin", b"http://localhost:5173")] + self.assertTrue(server.HostOriginValidator._is_allowed(scope)) + + +class BrowserUrlTests(unittest.TestCase): + def test_only_public_http_urls_are_opened(self): + guard = server.public_url + self.assertIn("only http", guard("file:///etc/passwd")) + self.assertIn("only http", guard("ftp://example.com/")) + self.assertIn("no host", guard("http://")) + for url in ("http://127.0.0.1:8888/api/kernels", "http://localhost/", "http://10.0.0.5/", + "http://169.254.169.254/latest/meta-data/", "http://192.168.64.1:8223/", "http://[::1]/", + "http://0.0.0.0/"): + with self.subTest(url=url): + self.assertIn("not a public address", guard(url)) + self.assertIn("not a public address", guard("http://100.64.1.2/")) # shared space (CGNAT, tailnets) + self.assertIn("not a public address", guard("http://224.0.0.1/")) + self.assertIsNone(guard("https://93.184.216.34/")) # a public address, no DNS needed + self.assertIn("does not resolve", guard("http://no-such-host.invalid/")) + # The same decision with addresses the caller resolved, as every request the page makes is checked. + self.assertIn("not a public address", guard("http://example.com/", addresses={"93.184.216.34", "10.0.0.1"})) + self.assertIsNone(guard("http://example.com/", addresses={"93.184.216.34"})) + self.assertIsNone(asyncio.run(server.checked_url("https://93.184.216.34/"))) + self.assertIn("not a public address", asyncio.run(server.checked_url("http://127.0.0.1:8888/"))) + + +class SkillArchiveTests(unittest.TestCase): + def setUp(self): + server.USER_SKILLS_DIR.mkdir(parents=True, exist_ok=True) + self.addCleanup(shutil.rmtree, server.USER_SKILLS_DIR, True) + + def archive(self, name, members): + path = server.USER_SKILLS_DIR / name + with zipfile.ZipFile(path, "w") as archive: + for member, data in members: + archive.writestr(member, data) + return path + + def test_archives_cannot_escape_or_explode(self): + slip = self.archive("slip.zip", [("../outside.txt", "x")]) + with self.assertRaises(ValueError): + server._extract_skill_archive(slip) + self.assertFalse((server.USER_SKILLS_DIR.parent / "outside.txt").exists()) + with mock.patch.object(server, "MAX_SKILL_ARCHIVE_BYTES", 10): + big = self.archive("big.zip", [("skill/SKILL.md", "x" * 100)]) + with self.assertRaises(ValueError): + server._extract_skill_archive(big) + good = self.archive("good.zip", [("my-skill/SKILL.md", "---\nname: my-skill\ndescription: Does a thing\n---\n")]) + server._extract_skill_archive(good) + self.assertTrue((server.USER_SKILLS_DIR / "my-skill" / "SKILL.md").is_file()) + + def test_a_bad_archive_is_set_aside_and_listing_goes_on(self): + (server.USER_SKILLS_DIR / "broken.zip").write_bytes(b"not a zip") + locked = self.archive("locked.zip", [("locked-skill/SKILL.md", "x")]) + with mock.patch.object(server, "_extract_skill_archive", side_effect=RuntimeError("File is encrypted")): + listing = asyncio.run(server.list_skills()) + self.assertTrue((server.USER_SKILLS_DIR / "locked.zip.bad").exists()) # a password-protected zip + self.assertFalse(locked.exists()) + self.archive("ok.zip", [("ok-skill/SKILL.md", "---\nname: ok-skill\ndescription: Fine\n---\n")]) + listing = asyncio.run(server.list_skills()) + self.assertIn("ok-skill", listing) + self.assertTrue((server.USER_SKILLS_DIR / "broken.zip.bad").exists()) + self.assertFalse((server.USER_SKILLS_DIR / "ok.zip").exists()) + + +class SessionIdTests(unittest.TestCase): + def test_session_ids_are_validated_before_a_kernel_is_taken(self): + manager = server.PythonSessionManager() + with mock.patch.object(server.kernel_pool, "initialize", mock.AsyncMock()), \ + mock.patch.object(server.kernel_pool, "get_available_kernel", mock.AsyncMock(return_value="k1")) as taken: + for bad in ("-leading", "has space", "x" * 65, "../etc", 42): # "" means "pick one for me" + with self.subTest(bad=bad), self.assertRaises(ValueError): + asyncio.run(manager.start(bad)) + taken.assert_not_awaited() + info = asyncio.run(manager.start("job.1")) + self.assertEqual(info["session_id"], "job.1") + with self.assertRaises(server.SessionConflictError): + asyncio.run(manager.start("job.1")) + + +class HealthTests(unittest.IsolatedAsyncioTestCase): + async def test_health_is_degraded_without_jupyter_and_needs_no_token(self): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=server.app), base_url="http://localhost:8222") as client: + response = await client.get("/health") + self.assertEqual(response.status_code, 503) + self.assertEqual(response.json()["jupyter"], False) + # Every other route needs the token, and a foreign host is refused before anything else. + self.assertEqual((await client.post("/execute", json={"code": "1"})).status_code, 401) + self.assertEqual((await client.get("/health", headers={"Host": "evil.example"})).status_code, 403) + + +if __name__ == "__main__": + unittest.main()