From 5e5a959bb35f50f4b23cf4e44a54fafe8a8ebad7 Mon Sep 17 00:00:00 2001 From: Abhishek Anand Date: Wed, 12 Aug 2026 05:34:33 +0530 Subject: [PATCH 1/8] Add resource and workspace mount controls --- README.md | 18 +++++++ install.sh | 138 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 153 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 86aa379..495123b 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,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. + ## Run Claude Code inside a Sandbox diff --git a/install.sh b/install.sh index 2726492..b7c7c50 100755 --- a/install.sh +++ b/install.sh @@ -1,5 +1,116 @@ #!/bin/bash +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 + +NORMALIZED_MOUNTS=() +for spec in "${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[@]}"; 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 + 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}' @@ -87,6 +198,25 @@ ASSETS_SRC="$HOME/.coderunner/assets" mkdir -p "$ASSETS_SRC/skills/user" mkdir -p "$ASSETS_SRC/outputs" +CONFIG_FILE="$HOME/.coderunner/container-config" +desired_config="cpus=$CPUS"$'\n'"memory=$MEMORY" +for mount in "${NORMALIZED_MOUNTS[@]}"; do + desired_config="${desired_config}"$'\n'"mount=$mount" +done +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 @@ -105,15 +235,17 @@ 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[@]}" \ --name coderunner \ --detach \ - --cpus 8 \ - --memory 4g \ + --cpus "$CPUS" \ + --memory "$MEMORY" \ instavm/coderunner; then + printf '%s' "$desired_config" > "$CONFIG_FILE" echo "✅ Setup complete. MCP server is available at http://coderunner.local:8222/mcp" else echo "❌ Failed to start coderunner container. Please check the logs with: container logs coderunner" From 0308d81c65d47760fa7bcc5ea1d47b4ee336b0b3 Mon Sep 17 00:00:00 2001 From: mkagenius Date: Wed, 12 Aug 2026 10:51:05 +0530 Subject: [PATCH 2/8] fix(install.sh): guard empty array expansions under set -u macOS ships bash 3.2, where expanding an empty array under 'set -u' is a fatal 'unbound variable' error. MOUNT_SPECS, NORMALIZED_MOUNTS and VOLUME_ARGS are all empty when no --mount is passed, which breaks once this branch is combined with the 'set -u' added in #24. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- install.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/install.sh b/install.sh index b7c7c50..947e644 100755 --- a/install.sh +++ b/install.sh @@ -58,7 +58,7 @@ if ! [[ "$MEMORY" =~ ^[1-9][0-9]*([KkMmGgTtPp][Bb]?)?$ ]]; then fi NORMALIZED_MOUNTS=() -for spec in "${MOUNT_SPECS[@]}"; do +for spec in ${MOUNT_SPECS[@]+"${MOUNT_SPECS[@]}"}; do source_path=${spec%%:*} remainder=${spec#*:} if [ "$remainder" = "$spec" ] || [ -z "$source_path" ] || [ -z "$remainder" ]; then @@ -95,7 +95,7 @@ for spec in "${MOUNT_SPECS[@]}"; do echo "Error: mount target contains an invalid path segment." >&2 exit 2 fi - for existing in "${NORMALIZED_MOUNTS[@]}"; do + for existing in ${NORMALIZED_MOUNTS[@]+"${NORMALIZED_MOUNTS[@]}"}; do existing_target=${existing#*:} existing_target=${existing_target%:*} if [ "$existing_target" = "$target_path" ]; then @@ -200,7 +200,7 @@ mkdir -p "$ASSETS_SRC/outputs" CONFIG_FILE="$HOME/.coderunner/container-config" desired_config="cpus=$CPUS"$'\n'"memory=$MEMORY" -for mount in "${NORMALIZED_MOUNTS[@]}"; do +for mount in ${NORMALIZED_MOUNTS[@]+"${NORMALIZED_MOUNTS[@]}"}; do desired_config="${desired_config}"$'\n'"mount=$mount" done if container inspect coderunner &>/dev/null; then @@ -239,7 +239,7 @@ 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[@]+"${VOLUME_ARGS[@]}"} \ --name coderunner \ --detach \ --cpus "$CPUS" \ From fc9fdd5bee480a2e48186a65635f1e3240ee311f Mon Sep 17 00:00:00 2001 From: Abhishek Anand Date: Wed, 12 Aug 2026 11:07:41 +0530 Subject: [PATCH 3/8] Address installer review feedback --- install.sh | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/install.sh b/install.sh index 947e644..09c5b42 100755 --- a/install.sh +++ b/install.sh @@ -56,6 +56,8 @@ 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 @@ -107,6 +109,7 @@ for spec in ${MOUNT_SPECS[@]+"${MOUNT_SPECS[@]}"}; do 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 @@ -200,9 +203,10 @@ mkdir -p "$ASSETS_SRC/outputs" CONFIG_FILE="$HOME/.coderunner/container-config" desired_config="cpus=$CPUS"$'\n'"memory=$MEMORY" -for mount in ${NORMALIZED_MOUNTS[@]+"${NORMALIZED_MOUNTS[@]}"}; do - desired_config="${desired_config}"$'\n'"mount=$mount" -done +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 @@ -227,6 +231,11 @@ if container start coderunner 2>/dev/null; then echo "✅ Setup complete. MCP server is available at http://coderunner.local:8222/mcp" exit 0 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 From 34d59cb202e9cf4814000cb3c42ca9117bcc46b2 Mon Sep 17 00:00:00 2001 From: Abhishek Anand Date: Wed, 12 Aug 2026 11:47:03 +0530 Subject: [PATCH 4/8] Keep MCP on the compatible API --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 52ecaad..03f8d89 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,7 +31,7 @@ openai requests>=2.33.0 -mcp[cli] +mcp[cli]>=1.26,<2 fastmcp From a8221a9a5905167445fd42492d5b815d54da139f Mon Sep 17 00:00:00 2001 From: mkagenius Date: Tue, 6 Oct 2026 22:50:15 +0530 Subject: [PATCH 5/8] Require a bearer token on the MCP and REST routes The server in the sandbox had no authentication: anything that could reach port 8222 (every process on the Mac; the LAN, when Docker published the port on all interfaces) could run code in it. Every route except GET /health now needs Authorization: Bearer ; a missing or weak server token fails closed with 503. The installer generates the token once into ~/.coderunner/api-token (0600), or saves the CODERUNNER_API_TOKEN you export, passes it to the container by name, and checks that anonymous requests are refused and the token is accepted before reporting the sandbox ready. Docker Compose and the Docker instructions publish the port on 127.0.0.1 only. The examples, the Claude Desktop proxy and test-sessions.py send the header. Verify: tests/test_auth.py; ./install.sh then curl without and with the header. --- AUTHENTICATION.md | 39 +++++++++++++++++ DOCKER_SETUP.md | 15 ++++--- Dockerfile | 1 + README.md | 17 +++++++- auth.py | 40 +++++++++++++++++ docker-compose.yml | 5 ++- examples/claude_desktop/mcpproxy.py | 5 ++- examples/openai_agents/openai_client.py | 4 +- install.sh | 45 +++++++++++++++++-- server.py | 3 +- test-sessions.py | 6 ++- tests/test_auth.py | 57 +++++++++++++++++++++++++ 12 files changed, 219 insertions(+), 18 deletions(-) create mode 100644 AUTHENTICATION.md create mode 100644 auth.py create mode 100644 tests/test_auth.py diff --git a/AUTHENTICATION.md b/AUTHENTICATION.md new file mode 100644 index 0000000..87d8f77 --- /dev/null +++ b/AUTHENTICATION.md @@ -0,0 +1,39 @@ +# Authentication and network boundaries + +This unreleased branch adds a bearer-credential boundary around both MCP and +REST routes. Set `CODERUNNER_API_TOKEN` to an existing operator-managed secret of +at least 32 bytes with no whitespace. Every request except `GET/HEAD /health` +requires an 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 retains its existing network choices and now passes the +credential 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. Recreating a container +with an authenticated build is an explicit operator action. + +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. + +No image was built or launched for this change. Previously published images +may be unauthenticated. Source-level middleware tests cover HTTP, WebSocket, +weak/missing configuration, duplicated credentials and rotation; a real image, +installer, network and client compatibility check is still required before +release. The installer can perform downloads, system restarts and DNS changes; +do not use it as a read-only verifier. diff --git a/DOCKER_SETUP.md b/DOCKER_SETUP.md index 3b2a370..f8f66f8 100644 --- a/DOCKER_SETUP.md +++ b/DOCKER_SETUP.md @@ -33,16 +33,19 @@ The Docker build workflow runs on: ### Quick Start with Docker +Export an existing operator-managed `CODERUNNER_API_TOKEN` (at least 32 bytes, no whitespace) before starting the container or clients. This branch is unreleased; build an authenticated image from this checkout and substitute its local tag below. Previously published images may ignore the token. 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 73f9d51..62f9ddd 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,18 @@ 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+ +This branch requires an operator-supplied `CODERUNNER_API_TOKEN` of at least 32 +bytes, without whitespace. Export it before running the installer and supply the +same secret to each client as `Authorization: Bearer `. Only `GET /health` +and `HEAD /health` are public. Missing or weak server configuration fails closed. +Do not put the credential in a URL or commit it. Desktop apps may need the +environment variable configured explicitly rather than inheriting your shell. + +These changes are local and unreleased: previously published images do not gain +authentication automatically. The installer checks both anonymous rejection and +authenticated access, and refuses a legacy image that ignores the credential. +See [authentication and network boundaries](AUTHENTICATION.md) before testing. + ```bash git clone https://github.com/instavm/coderunner.git cd coderunner @@ -32,7 +44,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 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/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..2813e25 100644 --- a/examples/claude_desktop/mcpproxy.py +++ b/examples/claude_desktop/mcpproxy.py @@ -1,8 +1,10 @@ -from fastmcp import FastMCP +import os import socket import sys +from fastmcp import FastMCP + def resolve_with_system_dns(hostname): try: @@ -27,6 +29,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 " + os.environ["CODERUNNER_API_TOKEN"]}, "transport": "http" } } diff --git a/examples/openai_agents/openai_client.py b/examples/openai_agents/openai_client.py index b5897d9..90fa19d 100644 --- a/examples/openai_agents/openai_client.py +++ b/examples/openai_agents/openai_client.py @@ -1,13 +1,12 @@ import asyncio +import os import socket - from agents import Agent, Runner, gen_trace_id, trace from agents.mcp import MCPServer, MCPServerStreamableHttp from agents.model_settings import ModelSettings - async def run(mcp_server: MCPServer): agent = Agent( name="Assistant", @@ -41,6 +40,7 @@ async def main(): name="SSE Python Server", params={ "url": f"http://{address}:8222/mcp", + "headers": {"Authorization": "Bearer " + os.environ["CODERUNNER_API_TOKEN"]}, "sse_read_timeout": 60, "timeout": 60, }, diff --git a/install.sh b/install.sh index f53ec90..4117c7d 100755 --- a/install.sh +++ b/install.sh @@ -1,6 +1,31 @@ #!/bin/bash set -u +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}" @@ -297,11 +322,24 @@ 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 + echo "❌ The server must reject anonymous requests and accept the configured credential." + echo " An image built before authentication ignores the token: recreate the sandbox with" + echo " container delete coderunner && ./install.sh" + return 1 + fi + echo "✅ Setup complete. MCP server: http://$MCP_HOST:8222/mcp" + echo " Clients send: Authorization: Bearer \$(cat $CODERUNNER_HOME/api-token)" return 0 fi sleep 2 @@ -378,6 +416,7 @@ if container run \ --detach \ --cpus "$CPUS" \ --memory "$MEMORY" \ + --env CODERUNNER_API_TOKEN \ ${NETWORK_ARGS[@]+"${NETWORK_ARGS[@]}"} \ instavm/coderunner; then printf '%s' "$desired_config" > "$CONFIG_FILE" diff --git a/server.py b/server.py index e7aba9d..f528922 100644 --- a/server.py +++ b/server.py @@ -29,6 +29,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" @@ -1242,4 +1243,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-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}]) From c8874bc1f7655e2f045ee473c3ec590d45ca06dc Mon Sep 17 00:00:00 2001 From: Abhishek Anand Date: Wed, 4 Nov 2026 16:08:41 +0530 Subject: [PATCH 6/8] Harden the sandbox server and test it in CI - The browser tool opens only http and https URLs whose host resolves to a public address: from inside the sandbox, loopback, link-local and private addresses reach the Jupyter server, the host gateway and the LAN. Pages load with a 30 s timeout, the browser is always closed, and the text is cut at 200,000 characters. - Code that was sent to a kernel is never sent again. The retry loop only moves to another kernel when the first could not be reached; 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. - /health answers 503 unless the Jupyter server that runs the kernels answers too, so the installer and the container healthcheck see a sandbox whose kernels cannot start. - Skill archives are bounded to 200 MB unpacked, and a bad one is set aside as .zip.bad instead of failing every list_skills call. - The shared directory and the Jupyter URL come from the environment, so server.py imports outside the image; tests/test_server.py covers the request validation, the URL guard, skill archives, session ids and the health check, and tests/test_auth.py the token. A Tests workflow installs requirements.txt and runs them with ruff and shellcheck on every pull request. - The Claude Desktop proxy and the OpenAI example read the token from ~/.coderunner/api-token when CODERUNNER_API_TOKEN is not set; test-e2e.sh sends it and checks that a wrong one is refused. - README: when the sandbox helps with Claude Code (#13), and what to check when Claude Desktop shows the tool as disabled (#7). --- .github/workflows/tests.yml | 34 +++++ AUTHENTICATION.md | 33 +++-- DOCKER_SETUP.md | 2 +- README.md | 34 +++-- cleanup.sh | 1 + examples/claude_desktop/mcpproxy.py | 18 ++- examples/openai_agents/openai_client.py | 19 ++- server.py | 160 ++++++++++++++++-------- test-e2e.sh | 8 ++ tests/test_server.py | 127 +++++++++++++++++++ 10 files changed, 353 insertions(+), 83 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 tests/test_server.py 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 index 87d8f77..2043b22 100644 --- a/AUTHENTICATION.md +++ b/AUTHENTICATION.md @@ -1,20 +1,21 @@ # Authentication and network boundaries -This unreleased branch adds a bearer-credential boundary around both MCP and -REST routes. Set `CODERUNNER_API_TOKEN` to an existing operator-managed secret of -at least 32 bytes with no whitespace. Every request except `GET/HEAD /health` -requires an 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. +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 retains its existing network choices and now passes the -credential 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. Recreating a container -with an authenticated build is an explicit operator action. +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 @@ -31,9 +32,7 @@ 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. -No image was built or launched for this change. Previously published images -may be unauthenticated. Source-level middleware tests cover HTTP, WebSocket, -weak/missing configuration, duplicated credentials and rotation; a real image, -installer, network and client compatibility check is still required before -release. The installer can perform downloads, system restarts and DNS changes; -do not use it as a read-only verifier. +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 f8f66f8..451dd5c 100644 --- a/DOCKER_SETUP.md +++ b/DOCKER_SETUP.md @@ -33,7 +33,7 @@ The Docker build workflow runs on: ### Quick Start with Docker -Export an existing operator-managed `CODERUNNER_API_TOKEN` (at least 32 bytes, no whitespace) before starting the container or clients. This branch is unreleased; build an authenticated image from this checkout and substitute its local tag below. Previously published images may ignore the token. See [authentication boundaries](AUTHENTICATION.md). +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 diff --git a/README.md b/README.md index 62f9ddd..2ed8eb4 100644 --- a/README.md +++ b/README.md @@ -17,17 +17,14 @@ 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+ -This branch requires an operator-supplied `CODERUNNER_API_TOKEN` of at least 32 -bytes, without whitespace. Export it before running the installer and supply the -same secret to each client as `Authorization: Bearer `. Only `GET /health` -and `HEAD /health` are public. Missing or weak server configuration fails closed. -Do not put the credential in a URL or commit it. Desktop apps may need the -environment variable configured explicitly rather than inheriting your shell. - -These changes are local and unreleased: previously published images do not gain -authentication automatically. The installer checks both anonymous rejection and -authenticated access, and refuses a legacy image that ignores the credential. -See [authentication and network boundaries](AUTHENTICATION.md) before testing. +### 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 @@ -86,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) @@ -147,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 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/examples/claude_desktop/mcpproxy.py b/examples/claude_desktop/mcpproxy.py index 2813e25..258dce8 100644 --- a/examples/claude_desktop/mcpproxy.py +++ b/examples/claude_desktop/mcpproxy.py @@ -1,8 +1,24 @@ 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 @@ -29,7 +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 " + os.environ["CODERUNNER_API_TOKEN"]}, + "headers": {"Authorization": "Bearer " + api_token()}, "transport": "http" } } diff --git a/examples/openai_agents/openai_client.py b/examples/openai_agents/openai_client.py index 90fa19d..59cf8d8 100644 --- a/examples/openai_agents/openai_client.py +++ b/examples/openai_agents/openai_client.py @@ -1,12 +1,29 @@ 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( name="Assistant", @@ -40,7 +57,7 @@ async def main(): name="SSE Python Server", params={ "url": f"http://{address}:8222/mcp", - "headers": {"Authorization": "Bearer " + os.environ["CODERUNNER_API_TOKEN"]}, + "headers": {"Authorization": "Bearer " + api_token()}, "sse_read_timeout": 60, "timeout": 60, }, diff --git a/server.py b/server.py index f528922..3e72135 100644 --- a/server.py +++ b/server.py @@ -1,7 +1,5 @@ # --- IMPORTS --- import asyncio -import base64 -import binascii import json import logging import os @@ -75,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 @@ -85,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 @@ -101,7 +102,35 @@ 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) -> 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 that resolve to public addresses. The browser runs inside the sandbox, where loopback, link-local + (cloud metadata) and private addresses reach the Jupyter server, the host and the LAN.""" + 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" + try: + addresses = {info[4][0] for info in socket.getaddrinfo(host, parsed.port or 80, proto=socket.IPPROTO_TCP)} + except socket.gaierror: + return f"{host} does not resolve" + for address in addresses: + ip = ipaddress.ip_address(address.split("%")[0]) + if ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_multicast or ip.is_reserved \ + or ip.is_unspecified: + return f"{host} is not a public address" + return None # --- CUSTOM EXCEPTIONS --- @@ -121,6 +150,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 @@ -248,7 +281,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 @@ -498,7 +531,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): @@ -516,11 +551,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 @@ -537,8 +575,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, @@ -547,6 +585,7 @@ async def _execute_on_kernel(kernel_id: str, command: str, ctx: Context) -> str: ) as jupyter_ws: sent_msg_id, jupyter_request_json = create_jupyter_request(command) await jupyter_ws.send(jupyter_request_json) + sent = True logger.info(f"Sent execute_request to kernel {kernel_id} (msg_id: {sent_msg_id})") execution_complete = False @@ -615,17 +654,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() @@ -687,28 +728,25 @@ 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 = public_url(url) + if reason: + return f"Error: {reason}" 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.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)}" @@ -717,7 +755,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 @@ -741,10 +779,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}") @@ -779,12 +823,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 (ValueError, zipfile.BadZipFile, OSError) as exc: + logger.warning(f"Skill archive {item.name} not unpacked: {exc}") + item.rename(item.with_suffix(".zip.bad")) skills = { "public": [], @@ -811,7 +860,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"]: @@ -826,7 +875,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 @@ -857,7 +906,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 @@ -867,7 +916,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 @@ -963,7 +1012,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 @@ -995,8 +1044,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): 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/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..b115ec7 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,127 @@ +"""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.assertIsNone(guard("https://93.184.216.34/")) # a public address, no DNS needed + self.assertIn("does not resolve", guard("http://no-such-host.invalid/")) + + +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") + 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() From b049cc32bfeeccb808c240bc4929fa444ef60d35 Mon Sep 17 00:00:00 2001 From: Abhishek Anand Date: Wed, 4 Nov 2026 16:31:40 +0530 Subject: [PATCH 7/8] Keep a newer container tool, refuse sudo, and print the client command - The installer replaced any installed Apple container whose version was not its pin, which on a Mac with 1.5.0 meant a downgrade to 1.2.2 with sudo. It now installs only when the tool is missing or older than the pin. - Run as root, the installer put the token, the venv and the assets under /var/root where the clients run as the user never find them. It refuses to run as root; the steps that need it ask for sudo themselves. The README and the skills guide no longer say sudo ./install.sh. - When the sandbox is ready the installer prints the claude mcp add command with the token, and the README's Claude Code, Gemini CLI and OpenCode snippets carry the header. The Claude Code plugin predates the token and the README says so until it is updated. --- README.md | 17 +++++++++++------ SKILLS-README.md | 2 +- install.sh | 23 +++++++++++++++++------ 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 2ed8eb4..d664c64 100644 --- a/README.md +++ b/README.md @@ -168,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: @@ -227,6 +230,7 @@ Create or edit `~/.config/opencode/opencode.json`: "coderunner": { "type": "remote", "url": "http://coderunner.local:8222/mcp", + "headers": { "Authorization": "Bearer " }, "enabled": true } } @@ -272,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/install.sh b/install.sh index 4117c7d..32e8482 100755 --- a/install.sh +++ b/install.sh @@ -1,6 +1,12 @@ #!/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 @@ -178,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 @@ -339,7 +347,10 @@ wait_for_server() { return 1 fi echo "✅ Setup complete. MCP server: http://$MCP_HOST:8222/mcp" - echo " Clients send: Authorization: Bearer \$(cat $CODERUNNER_HOME/api-token)" + 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 From 63d2dd86566596cfc63090a56b673ed96594313e Mon Sep 17 00:00:00 2001 From: Abhishek Anand Date: Wed, 4 Nov 2026 17:27:00 +0530 Subject: [PATCH 8/8] Check every browser request, count a failed send as sent, stop a sandbox that ignores the token - The browser tool checks each request the page makes (redirects, frames, subresources) the way it checks the first URL, through a Playwright route; the address test uses is_global, which also refuses the shared 100.64/10 space, and the DNS lookup runs in a thread with a ten-second bound instead of on the event loop. A name that changes its address between the check and the browser's own resolution is not caught, and the code says so. - A send that raises after the request may have reached the kernel; it now counts as sent, so only failures before the send are retried. - Any failure to unpack a skill archive sets it aside, including the RuntimeError a password-protected zip raises. - When the running image ignores the token, the installer stops the sandbox before reporting the failure instead of leaving it reachable. --- install.sh | 8 +++--- server.py | 60 +++++++++++++++++++++++++++++++++++--------- tests/test_server.py | 12 +++++++++ 3 files changed, 65 insertions(+), 15 deletions(-) diff --git a/install.sh b/install.sh index 32e8482..7316b86 100755 --- a/install.sh +++ b/install.sh @@ -341,9 +341,11 @@ wait_for_server() { -H @<(printf 'Authorization: Bearer %s\n' "$CODERUNNER_API_TOKEN") \ "http://$MCP_HOST:8222/v1/sessions/session") if [ "$auth_code" != "200" ] || [ "$unauth_code" != "401" ]; then - echo "❌ The server must reject anonymous requests and accept the configured credential." - echo " An image built before authentication ignores the token: recreate the sandbox with" - echo " container delete coderunner && ./install.sh" + # 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" diff --git a/server.py b/server.py index 3e72135..f0ae55d 100644 --- a/server.py +++ b/server.py @@ -107,10 +107,12 @@ def resolve_with_system_dns(hostname): MAX_PAGE_TEXT = 200_000 # characters returned from a page -def public_url(url: str) -> str | None: +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 that resolve to public addresses. The browser runs inside the sandbox, where loopback, link-local - (cloud metadata) and private addresses reach the Jupyter server, the host and the LAN.""" + 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) @@ -121,17 +123,40 @@ def public_url(url: str) -> str | None: host = parsed.hostname if not host: return "the URL has no host" - try: - addresses = {info[4][0] for info in socket.getaddrinfo(host, parsed.port or 80, proto=socket.IPPROTO_TCP)} - except socket.gaierror: - return f"{host} does not resolve" + 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 ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_multicast or ip.is_reserved \ - or ip.is_unspecified: + 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 --- class KernelError(Exception): @@ -584,8 +609,8 @@ 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) - sent = True logger.info(f"Sent execute_request to kernel {kernel_id} (msg_id: {sent_msg_id})") execution_complete = False @@ -728,14 +753,25 @@ async def navigate_and_get_all_visible_text(url: str) -> str: Args: url: The URL of the webpage from which to retrieve text. """ - reason = public_url(url) + 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: async with async_playwright() as p: browser = await p.chromium.connect(PLAYWRIGHT_WS_URL) 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: @@ -831,7 +867,7 @@ async def list_skills() -> str: try: _extract_skill_archive(item) os.remove(item) - except (ValueError, zipfile.BadZipFile, OSError) as exc: + 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")) diff --git a/tests/test_server.py b/tests/test_server.py index b115ec7..9d5bc96 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -59,8 +59,15 @@ def test_only_public_http_urls_are_opened(self): "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): @@ -90,6 +97,11 @@ def test_archives_cannot_escape_or_explode(self): 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)