diff --git a/README.md b/README.md index a66d552..6cd81f1 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. + ### 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: diff --git a/install.sh b/install.sh index b574e12..f53ec90 100755 --- a/install.sh +++ b/install.sh @@ -19,6 +19,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}' @@ -194,10 +308,31 @@ wait_for_server() { done echo "❌ The container started but the MCP server did not respond within 120 seconds." echo " Check the container logs with: container logs coderunner" - echo " If coderunner.local does not resolve, verify DNS setup with: container system property list" + echo " If coderunner.local does not resolve, verify domain = \"local\" in ~/.config/container/config.toml" + echo " and check the DNS service with: container system dns list" 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 @@ -221,6 +356,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 @@ -229,16 +369,18 @@ 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" \ ${NETWORK_ARGS[@]+"${NETWORK_ARGS[@]}"} \ instavm/coderunner; then + printf '%s' "$desired_config" > "$CONFIG_FILE" wait_for_server exit $? else 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 diff --git a/server.py b/server.py index 2f0f7b1..2088b97 100644 --- a/server.py +++ b/server.py @@ -5,14 +5,17 @@ import json import logging import os -import zipfile import pathlib +import shutil +import stat import time import uuid +import zipfile from typing import Dict, Optional, Set from dataclasses import dataclass, field from enum import Enum from datetime import datetime, timedelta +from urllib.parse import urlsplit import aiofiles import websockets @@ -35,7 +38,9 @@ # Extra hostnames (comma-separated) that may be used to reach this server, # e.g. a LAN IP or a custom DNS name. EXTRA_ALLOWED_HOSTNAMES = [ - h.strip() for h in os.environ.get("CODERUNNER_EXTRA_HOSTS", "").split(",") if h.strip() + h.strip().lower() + for h in os.environ.get("CODERUNNER_EXTRA_HOSTS", "").split(",") + if h.strip() ] # Configure DNS rebinding protection to allow coderunner.local @@ -600,7 +605,23 @@ def _extract_skill_archive(archive_path: pathlib.Path) -> None: target = (destination / member.filename).resolve() if not target.is_relative_to(destination): raise ValueError(f"Unsafe path in skill archive: {member.filename}") - archive.extractall(destination) + + mode = member.external_attr >> 16 + file_type = stat.S_IFMT(mode) + if file_type not in (0, stat.S_IFREG, stat.S_IFDIR): + raise ValueError(f"Unsupported file type in skill archive: {member.filename}") + + for member in archive.infolist(): + target = destination / member.filename + if member.is_dir(): + target.mkdir(parents=True, exist_ok=True) + continue + + target.parent.mkdir(parents=True, exist_ok=True) + if target.is_symlink(): + raise ValueError(f"Unsafe symlink target in skill archive: {member.filename}") + with archive.open(member, "r") as source, target.open("wb") as output: + shutil.copyfileobj(source, output) @mcp.tool() @@ -790,6 +811,21 @@ async def report_progress(self, progress: int, message: str): ALLOWED_HOSTNAMES = {"localhost", "127.0.0.1", "coderunner.local", "0.0.0.0", *EXTRA_ALLOWED_HOSTNAMES} +def _header_hostname(value: str, *, origin: bool = False) -> Optional[str]: + try: + parsed = urlsplit(value if origin else f"//{value}") + if origin and parsed.scheme.lower() not in {"http", "https"}: + return None + if parsed.username is not None or parsed.password is not None: + return None + if parsed.path or parsed.query or parsed.fragment: + return None + parsed.port + return parsed.hostname.lower() if parsed.hostname else None + except ValueError: + return None + + class HostOriginValidator: def __init__(self, asgi_app): self.asgi_app = asgi_app @@ -804,12 +840,12 @@ async def __call__(self, scope, receive, send): @staticmethod def _is_allowed(scope) -> bool: headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]} - host = headers.get("host", "").rsplit(":", 1)[0] + host = _header_hostname(headers.get("host", "")) if host not in ALLOWED_HOSTNAMES: return False origin = headers.get("origin") if origin: - origin_host = origin.split("://", 1)[-1].rsplit(":", 1)[0] + origin_host = _header_hostname(origin, origin=True) if origin_host not in ALLOWED_HOSTNAMES: return False return True diff --git a/test-e2e.sh b/test-e2e.sh index c7f1eb0..5ce41d9 100755 --- a/test-e2e.sh +++ b/test-e2e.sh @@ -83,26 +83,30 @@ fi # 8. Zip archives cannot write outside the user skills directory if [ -d "$SKILLS_DIR" ]; then - marker="$(dirname "$SKILLS_DIR")/zip-slip-check" - rm -f "$marker" - python3 - "$SKILLS_DIR/unsafe.zip" <<'PY' + if [ -z "$sid" ]; then + check "skill archive traversal is rejected (no MCP session)" "session" "none" + else + marker="$(dirname "$SKILLS_DIR")/zip-slip-check" + rm -f "$marker" + python3 - "$SKILLS_DIR/unsafe.zip" <<'PY' import sys import zipfile with zipfile.ZipFile(sys.argv[1], "w") as archive: archive.writestr("../zip-slip-check", "unsafe") PY - curl -s -o /dev/null -X POST "$BASE/mcp" \ - -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ - -H "mcp-session-id: $sid" \ - -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_skills","arguments":{}}}' - if [ -e "$marker" ]; then - check "skill archive traversal is rejected" "rejected" "leaked" - rm -f "$marker" - else - check "skill archive traversal is rejected" "rejected" "rejected" + curl -s -o /dev/null -X POST "$BASE/mcp" \ + -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ + -H "mcp-session-id: $sid" \ + -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_skills","arguments":{}}}' + if [ -e "$marker" ]; then + check "skill archive traversal is rejected" "rejected" "leaked" + rm -f "$marker" + else + check "skill archive traversal is rejected" "rejected" "rejected" + fi + rm -f "$SKILLS_DIR/unsafe.zip" fi - rm -f "$SKILLS_DIR/unsafe.zip" fi # 9. Jupyter must not be reachable from outside the container