# Aivory local Python sandbox (sidecar)

English · 简体中文

A tiny, self-hosted Python execution sandbox for local development. It implements the HTTP protocol the Go backend already speaks (`server/internal/sandbox/sandbox.go`), so wiring it up is just an env var. - [Complete sandbox guide](https://docs.aivorygo.com/docs/sandbox/overview/) - [Sandbox Sidecar API reference](https://docs.aivorygo.com/docs/sandbox/api/) It is **not** a from-scratch sandbox: each session is a locked-down Docker container running a pre-baked Python image. `/workspace` persists across `exec` calls within a session (like ChatGPT Code Interpreter), and Chinese renders correctly because the image ships Noto CJK fonts. ``` ┌────────────┐ POST /sessions /exec /files ┌──────────────┐ docker exec ┌──────────────────┐ │ Go backend │ ──────────────────────────────► │ app.py (this)│ ──────────────► │ session container │ └────────────┘ SANDBOX_BASE_URL └──────────────┘ │ aivory-sandbox │ └──────────────────┘ ``` ## What's in the runtime image - **Data science**: numpy, pandas, scipy, scikit-learn, statsmodels, matplotlib, seaborn, plotly (+ kaleido), pillow, sympy, networkx - **Documents (§4.5.1)**: python-pptx, python-docx, openpyxl, xlsxwriter, reportlab, weasyprint (HTML→PDF), markdown, jinja2 - **Utilities**: pypdf, tabulate, requests, lxml, beautifulsoup4, pyyaml - **Fonts**: Noto Sans CJK (SC/TC/JP/KR), Noto Color Emoji, DejaVu — matplotlib is pre-configured to use them, so no more tofu boxes (□□□) for Chinese. Heavy extras (Playwright/Chromium for HTML screenshots, LibreOffice for format conversion) are left commented at the bottom of `Dockerfile.runner` — uncomment if you need them; they add ~400–500MB each. ## Deploy: pull the public images and run (recommended) This project publishes ready-to-use images to GitHub Container Registry. Users do **not** need to build images, create their own GitHub repo, or log in to GHCR. The images are public: ``` ghcr.io/hjxwz123/aivory-sandbox:latest # Python runtime ghcr.io/hjxwz123/aivory-sandbox-sidecar:latest # control service ``` Both image tags support `linux/amd64` and `linux/arm64`; Docker automatically pulls the matching variant for the host. Local custom builds can target either architecture with Buildx, for example: ```bash docker buildx build --platform linux/amd64,linux/arm64 \ -f Dockerfile.runner -t example/aivory-sandbox:latest --push . docker buildx build --platform linux/amd64,linux/arm64 \ -f Dockerfile.sidecar -t example/aivory-sandbox-sidecar:latest --push . ``` **1. Clone the public repo on your server** ```bash git clone https://github.com/hjxwz123/aivory-sandbox.git cd aivory-sandbox ``` **2. Pull the public images** ```bash export OWNER=hjxwz123 docker pull ghcr.io/$OWNER/aivory-sandbox:latest docker pull ghcr.io/$OWNER/aivory-sandbox-sidecar:latest docker images "ghcr.io/$OWNER/aivory-sandbox*" ``` **3. Generate and display the API key** ```bash export SANDBOX_API_KEY=$(openssl rand -hex 24) printf 'SANDBOX_API_KEY=%s\n' "$SANDBOX_API_KEY" ``` Keep this value. The sidecar requires it for requests, and the Go backend must use the same key. **4. Start the service** ```bash docker compose pull docker compose up -d docker compose ps curl -H "Authorization: Bearer $SANDBOX_API_KEY" http://localhost:48217/healthz ``` **5. Point the Go backend at it:** ``` SANDBOX_BASE_URL=http://:48217 SANDBOX_API_KEY= ``` That's the whole loop. Users pull and run the public images; they do not build Docker images. --- ## 部署:拉取公开镜像并运行(推荐) 本项目已经把可直接使用的镜像发布到 GitHub Container Registry。使用者 不需要构建镜像,不需要创建自己的 GitHub 仓库,也不需要登录 GHCR。镜像 是公开的: ``` ghcr.io/hjxwz123/aivory-sandbox:latest # Python 运行时镜像 ghcr.io/hjxwz123/aivory-sandbox-sidecar:latest # 控制服务镜像 ``` **1. 在服务器上克隆公开仓库** ```bash git clone https://github.com/hjxwz123/aivory-sandbox.git cd aivory-sandbox ``` **2. 拉取公开镜像** ```bash export OWNER=hjxwz123 docker pull ghcr.io/$OWNER/aivory-sandbox:latest docker pull ghcr.io/$OWNER/aivory-sandbox-sidecar:latest docker images "ghcr.io/$OWNER/aivory-sandbox*" ``` **3. 生成并显示密钥** ```bash export SANDBOX_API_KEY=$(openssl rand -hex 24) printf 'SANDBOX_API_KEY=%s\n' "$SANDBOX_API_KEY" ``` 请保存输出的值。sidecar 会用它校验请求,Go 后端也必须配置同一个密钥。 **4. 启动服务** ```bash docker compose pull docker compose up -d docker compose ps curl -H "Authorization: Bearer $SANDBOX_API_KEY" http://localhost:48217/healthz ``` **5. 配置 Go 后端** ``` SANDBOX_BASE_URL=http://<服务器地址>:48217 SANDBOX_API_KEY=<上一步打印出来的同一个值> ``` 这样就完成了。使用者直接拉取并运行公开镜像,不需要构建 Docker 镜像。 --- ## Optional: build locally for development or customization Regular users should use the public images above. This section is only for maintainers or developers who want to change the runtime image. It requires a running Docker engine (Docker Desktop or Colima) and Python 3.10+. ```bash cd aivory-sandbox # 1. Build the runtime image (one-time, ~5–8 min, downloads the wheels + fonts) docker build -f Dockerfile.runner -t aivory-sandbox:latest . # 2. Install the sidecar deps and run it on :8000 python3 -m venv .venv && source .venv/bin/activate pip install -r requirements.txt # Auth is mandatory — the sidecar refuses to start without a key. Either set one: export SANDBOX_API_KEY=$(openssl rand -hex 24) # …or, for a trusted localhost-only dev box, explicitly opt out of auth: # export SANDBOX_ALLOW_NO_AUTH=1 uvicorn app:app --host 127.0.0.1 --port 8000 ``` Then set `SANDBOX_BASE_URL=http://127.0.0.1:8000` (and the same `SANDBOX_API_KEY`) for the Go server. ## Smoke test (no backend needed) ```bash export SANDBOX_URL=${SANDBOX_URL:-http://localhost:48217} SID=$(curl -s -XPOST "$SANDBOX_URL/sessions" \ -H "Authorization: Bearer $SANDBOX_API_KEY" \ | python3 -c 'import sys,json;print(json.load(sys.stdin)["session_id"])') curl -s -XPOST "$SANDBOX_URL/exec" \ -H "Authorization: Bearer $SANDBOX_API_KEY" \ -H 'content-type: application/json' \ -d "{ \"session_id\": \"$SID\", \"code\": \"import matplotlib.pyplot as plt; plt.plot([1,2,3]); plt.title('中文标题'); plt.savefig('/workspace/outputs/p.png'); print('rows', 3)\" }" | python3 -m json.tool ``` You should get `stdout: "rows 3\n"`, `exit_code: 0`, and one file `p.png` (base64) in `files` — with the Chinese title rendered, not boxes. ## Configuration (env vars) Runner networking is deliberately not configurable. Every new session container is created with Docker `--network none`, even when the sidecar itself is attached to a Compose network so the Go backend can reach it. User Python therefore cannot use `requests`, `curl`, or runtime `pip install` to fetch remote content. Add required packages to `Dockerfile.runner` and rebuild the runner image instead. | Var | Default | Notes | |---|---|---| | `SANDBOX_IMAGE` | `aivory-sandbox:latest` | runtime image tag | | `SANDBOX_MEMORY` | `2g` | per-container memory cap | | `SANDBOX_CPUS` | `1` | per-container CPU cap | | `SANDBOX_PIDS_LIMIT` | `256` | fork-bomb guard | | `SANDBOX_API_KEY` | _(empty)_ | **Required.** Bearer key clients must send. The sidecar **refuses to start** if this is empty/blank (it exposes host Docker control — RCE). Constant-time compared. | | `SANDBOX_ALLOW_NO_AUTH` | `0` | escape hatch: set `1` to start with **no auth** (trusted localhost-only dev only). | | `SANDBOX_MAX_BODY_BYTES` | `58720256` | reject requests whose `Content-Length` exceeds this (HTTP 413) before reading the body. 56 MiB, enough for a 40 MiB upload after base64 and JSON overhead. | | `SANDBOX_MAX_CODE_BYTES` | `1048576` | max bytes for the `/exec` `code` field (HTTP 413/422 over). 1 MiB. | | `SANDBOX_EXEC_TIMEOUT_CAP_MS` | `120000` | hard ceiling per exec (§4.5) | | `SANDBOX_IDLE_TTL_SECONDS` | `1800` | idle sessions reaped after 30 min (fallback when Go forwards no per-session `idle_ttl_sec`) | | `SANDBOX_IDLE_TTL_CAP_SECONDS` | `86400` | hard ceiling for the admin-forwarded idle TTL (`idle_ttl_sec` clamped to this). 24h. | | `SANDBOX_MAX_SESSIONS` | `16` | max active sandbox containers | | `SANDBOX_MAX_CONCURRENT_EXECS` | `4` | max concurrent `/exec` calls across sessions | | `SANDBOX_MAX_CONCURRENT_CREATES` | `2` | max concurrent Docker container creates | | `SANDBOX_QUEUE_TIMEOUT_SECONDS` | `150` | how long a request waits for an internal slot | | `SANDBOX_MAX_UPLOAD_BYTES` | `41943040` | max decoded size for one `/files` upload (40 MiB) | | `SANDBOX_MAX_FILES_PER_EXEC` | `20` | max returned artifacts per `/exec` | | `SANDBOX_MAX_TOTAL_ARTIFACT_BYTES` | `52428800` | max total returned artifact bytes per `/exec` | | `SANDBOX_READ_ONLY_ROOTFS` | `1` | **on by default** (F6 disk-fill guard): session rootfs is read-only; only `/tmp`, `$HOME` and `/workspace` are writable size-bounded tmpfs. Set `0` to disable. | | `SANDBOX_TMPFS_SIZE` | `256m` | tmpfs size for `/tmp` and `/home/sandbox` when read-only mode is on | | `SANDBOX_WORKSPACE_SIZE` | `512m` | size cap for the writable `/workspace` tmpfs when read-only mode is on (alias `SANDBOX_WORKSPACE_TMPFS_SIZE` still honoured) | | `SANDBOX_DISK_SIZE` | _(empty)_ | opt-in per-container writable-layer quota via `--storage-opt size=…`. Needs overlay2+prjquota; applied best-effort (retried without it if unsupported, never crashes). | | `SANDBOX_SECCOMP_PROFILE` | _(empty)_ | opt-in path (readable inside the sidecar) to a seccomp profile pinned on session containers via `--security-opt seccomp=…`. Unset = docker's default profile. | | `SANDBOX_NOFILE_ULIMIT` | `1024:1024` | per-container open-file ulimit | | `SANDBOX_STORAGE_DEFAULT_TTL` | `3600` | default presigned-GET ttl (seconds) for `/storage/put` when `expires_in` is omitted | | `SANDBOX_STORAGE_MAX_TTL` | `86400` | hard cap on the presigned-GET ttl (24h) | | `SANDBOX_MAX_ARCHIVE_BYTES` | `209715200` | max `/workspace` tar size archived on reap/delete; larger workspaces skip archive (logged). 200 MiB. | | `SANDBOX_MAX_STORAGE_BODY_BYTES` | `314572800` | body-size ceiling for `/storage/put` (RAG document uploads dwarf the F5 cap that guards `/exec`/`/files`). 300 MiB. | | `SANDBOX_LOCAL_STORAGE_DIR` | _(empty)_ | directory for the `local` archive backend (§4.5-F). When set (mount it as a volume), the `local` storage provider archives `/workspace` tarballs here — zero-config persistence, no S3/OSS/MinIO needed. Empty = `local` is inert (reaped = gone). **Single-node only** (a plain volume isn't shared across replicas); MinerU document parsing still needs an object store (no presigned URL for local files). Operator env, never admin/forwarded — the sidecar is root+docker.sock, so a caller-chosen path would be a host-write vector. | > **Storage providers.** The forwarded `storage.provider` is one of `local` (the default — archives to `SANDBOX_LOCAL_STORAGE_DIR`, no external store), `s3`, `aliyun_oss`, or empty. Workspace tarballs are keyed by the forwarded `archive_key` (the conversation id), so a workspace **survives session recycle** — each session gets a fresh id, but archive/restore use the stable key. **MinIO / any S3-compatible store**: pick `s3` and set `storage.s3_endpoint` — a custom endpoint auto-selects path-style addressing + SigV4, so it works with no extra flags. ## Security posture (dev-grade) Each session container runs **non-root**, mandatory `--network none`, `--cap-drop ALL`, `--security-opt no-new-privileges`, with memory/cpu/pids/nofile limits and a 120s exec timeout. By default the rootfs is **read-only** and only `/tmp`, `$HOME` and `/workspace` are writable size-bounded tmpfs mounts, so a session can't fill the host disk (set `SANDBOX_READ_ONLY_ROOTFS=0` to opt out; set `SANDBOX_DISK_SIZE` on an overlay2+prjquota host for a writable-layer quota too). You can pin a seccomp profile on session containers with `SANDBOX_SECCOMP_PROFILE`. `/exec` calls are serialized per session, globally rate-limited, and stdout/stderr are streamed through a 32KB cap before returning. Produced files are capped at 20MB each, 20 files, and 50MB total per exec — matching the §4.5 安全基线 while keeping the HTTP contract unchanged. **Auth is mandatory.** This service drives the host Docker daemon, so an unauthenticated reachable instance is host RCE. `SANDBOX_API_KEY` must be set or the sidecar refuses to start (`SANDBOX_ALLOW_NO_AUTH=1` only for a trusted localhost-only dev box). The key is compared in constant time. Request bodies are size-capped before they're read (`SANDBOX_MAX_BODY_BYTES`, plus a 1 MiB cap on `/exec` `code`) so a giant payload can't OOM the control plane. `/files` uploads are confined to `/workspace`: the destination is normalised **and** its real path is resolved inside the container (following any symlinked parent) and re-checked, so a symlinked path can't escape `/workspace`. **docker.sock is root-equivalent on the host.** The architecture needs it, but in production put a [docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy) in front of the raw socket restricted to container create/start/exec/kill + image pull (see the security note in `docker-compose.yml`), and never expose the service port to the public internet. On startup the sidecar also discovers existing `aivory.sandbox=1` containers, so a sidecar restart can keep tracking live sessions and reap stale ones later. This is container-level isolation, fine for a single-host dev box. It is **not** gVisor/microVM-grade. For production, replace the `docker run`/`docker exec` calls in `app.py` with a gVisor, Firecracker, or E2B backend — the HTTP contract and the Go side stay identical (that's the whole point of the thin adapter). ## Endpoints | Method | Path | Body | Returns | |---|---|---|---| | POST | `/sessions` | `{storage?, idle_ttl_sec?, archive_key?}` | `{session_id}` | | POST | `/exec` | `{session_id, code, timeout_ms?}` | `{stdout, stderr, exit_code, files[]}` | | POST | `/files` | `{session_id, path, data_base64}` | `{ok}` | | POST | `/files/get` | `{session_id, path}` | `{data_base64}` | | POST | `/files/list` | `{session_id}` | `{files[]}` | | POST | `/files/reset-inputs` | `{session_id}` | `{ok}` | | DELETE | `/sessions/{id}` | `{storage?, discard?, archive_key?}` | `{ok}` | | POST | `/storage/put` | `{key, data_base64, content_type, expires_in?, storage}` | `{provider, key, url, expires_in}` | | POST | `/storage/delete` | `{key, storage}` | `{ok, key}` | | GET | `/healthz` | — | `{ok, docker, image}` | `/files/get` reads a file back out of `/workspace` (same path-confinement and symlink guard as `/files`). 404 when the session or file is missing. `/storage/*` are object-storage operations against the admin-configured bucket (forwarded in the `storage` block: `provider` ∈ `s3`|`aliyun_oss`, plus `prefix` and the provider creds). `boto3`/`oss2` are lazy-imported only when a backend is actually used. `/storage/put` uploads the base64 bytes and returns a presigned GET URL (ttl `expires_in` or 1h, capped at 24h); `/storage/delete` is idempotent and refuses to delete outside the configured `prefix`. When a `storage` block is forwarded on `/sessions` and `DELETE /sessions/{id}`, the sidecar archives persistent workspace state to `/workspaces/.tgz` before a TTL/explicit teardown and restores it on the next create with that id (design §4.5). The server-managed `uploads` and `skills` input directories are excluded from archives and cleared after every restore. All best-effort: without an effective `storage` block this is a no-op (reaped = gone), and an archive/restore failure never fails the request. Artifacts in `files[]` are whatever Python wrote under `/workspace/outputs/` during that exec (`{name, mime_type, data_base64}`), including generated PNGs. `/files` accepts backend-authorized conversation uploads of any allowed format while retaining its workspace path, size, and symlink confinement. `/files/reset-inputs` clears `/workspace/uploads` and `/workspace/skills` without touching downloaded images, generated outputs, or other workspace state.