All projects

code-runner

A self-hostable service that runs untrusted code in a hardened sandbox and streams output, with live interactive stdin.

Stars
1 star
Created
Last push
License
MIT
Languages
  • Go 52%
  • TypeScript 23%
  • MDX 9%
  • Python 6%
  • Shell 4%

README

From the project README on GitHub.

An open-source, self-hostable remote code execution service with live interactive stdin.
Run untrusted code in a hardened sandbox and stream output in real time over soketi (Pusher-compatible).

Quickstart · Architecture · API · SDKs · Add a language · Deploy


What it is

code-runner is a Piston-style remote execution service, built as a polyglot monorepo, that does one thing well: take some code, run it in an isolated hardened sandbox with a live interactive stdin session, and stream the output back in real time — without ever leaking a container, a subscription, or a session slot.

It is an internal service: never exposed to the internet directly. In front of it sits your own backend (any stack), which authenticates with a bearer token. Browsers only ever receive output — over soketi — and never send trusted input directly.

Hardened by defaultnetwork=none, read-only rootfs, dropped capabilities, deny-by-default seccomp, non-root user, cgroup memory/CPU/PID limits — on every sandbox, unconditionally. gVisor is a one-env-var swap.
Live interactive stdinWrite to a running process mid-execution and watch stdout/stderr stream back chunk by chunk over WebSockets.
Three independent clocksWall, idle, and CPU time bound every sandbox. Any clock firing kills it unconditionally — no leaks, ever.
Polyglot by designA Hono/TypeScript gateway, a Go worker, and a JSON-Schema wire contract that generates TS types + Zod validators + Go structs from one source.
Add a language = a folderEach language is a manifest.json + Dockerfile, auto-discovered at boot. Zero changes to Go or the API.
Stateless & scalableAutoscale the worker fleet by Redis queue depth. Scale-to-zero friendly. MIT, self-hostable, BYO OpenTelemetry.

Architecture

All trusted input (code, stdin, control) enters through one bearer-authed door — the API. soketi is output-only toward the client; nothing trusted ever enters through it.

The components

  • apps/api — Hono / TypeScript. The single trusted entry point. Validates requests (constant-time bearer auth), resolves the language manifest, writes the job to Redis, and relays stdin & control signals. Stateless → runs anywhere, scales to N replicas.
  • apps/worker — Go. Claims jobs from Redis, launches hardened sandboxes via the host Docker daemon (behind a swappable Runner interface), enforces the three clocks, pumps stdin in and streams output out. The unit of scale.
  • Density tier (optional). A ZygoteRunner can run heavy interpreted languages (Python) on a warm copy-on-write pool for ~2.7× concurrency on Fly — off by default, falls back to per-job containers. See docs/zygote.md.
  • packages/contract — JSON Schema → TS + Zod + Go. The shared wire contract. One schema generates types for every language; a CI drift check guards the seam.
  • Redis — job queue (jobs:queue), stdin/control pub-sub (stdin:<id>, ctrl:<id>), and job metadata (job:<id>:{spec,status,output}).
  • soketi — Pusher-compatible WebSocket fan-out. The worker triggers; soketi delivers to subscribers.

Quickstart

Prerequisites

  • Docker Desktop running (cgroup v2 required for resource limits)
  • Node.js 22+ and pnpm 10+ · Go 1.26+
  • Ports 8080 (API) and 6001 (soketi) free

Five steps to a working interactive run

# 1. Copy env (all defaults are safe for local dev)
cp .env.example .env

# 2. Build the language sandbox images on the host daemon (no Docker-in-Docker)
make build-images       # executor/python:3.12  rust:1.83  c:14  cpp:14  r:4.4  sqlite:3

# 3. Bring up the stack: redis + soketi + api + worker
docker compose up       # or: make up

# 4. Run the interactive end-to-end demo
make e2e

# 5. Tear down
make down

make e2e drives an interactive Python program that prompts for a name, sends World over stdin, and asserts the round-trip:

[stub] stdout: name?
[stub] detected prompt, sending stdin: World
[stub] stdout: hello World
[stub] result: exitCode=0 reason=exit durationMs=...
[PASS] ===== E2E PASS: interactive execute hello World round-trip succeeded =====

Health check (no auth): curl http://localhost:8080/health → {"status":"ok"}.


Interactive flow

The start-handshake is the crux: the worker parks the job until you signal start, so your client is guaranteed to be subscribed before any output is emitted (soketi pub-sub drops events that have no live subscriber).

The three clocks

Each sandbox is bounded by three independent clocks; any one firing kills it:

ClockLimitFires when…Result field
WallwallTimeMstotal lifetime exceeds the limittimedOut
IdleidleMsno output and no stdin for the window (resets on activity)idleTimedOut
CPUcpuMscumulative cgroup CPU time exceeds the limittimedOut

Plus memoryMb (OOM kill, swap disabled), pids (fork-bomb guard), and outputKb (combined stdout+stderr budget; overflow → truncated).


API Reference

All /v1/* endpoints require Authorization: Bearer <EXECUTOR_API_TOKEN>. A missing or invalid token → 401 {"error":"unauthorized"} (constant-time comparison). GET /health is the only unauthenticated route.

MethodPathPurpose
GET/healthReadiness probe (no auth).
POST/v1/executeSubmit code → 202 {jobId, channel, status}.
POST/v1/jobs/:id/startThe start-handshake signal.
POST/v1/jobs/:id/stdinWrite a chunk to the process stdin.
POST/v1/jobs/:id/stdin/closeSignal EOF (Ctrl-D).
POST/v1/jobs/:id/killSIGKILL the sandbox.
GET/v1/jobs/:idPoll job status.
GET/v1/jobs/:id/outputPull persisted output (needs collectOutput).
GET/v1/languagesList discovered languages.
POST/v1/channel-authOptional soketi channel-auth helper (ENABLE_CHANNEL_AUTH=true).
POST /v1/execute — request & responses
// Request body
{
  "language": "python",            // name or alias: "py", "rust", "rs", "c", "cpp", "c++", "sqlite", "sql", …
  "version": "3.12",               // optional; omit to use the only/most-recent match
  "files": [
    { "name": "main.py", "content": "name = input('name? ')\nprint(f'hello {name}')\n" },
    { "name": "data/input.csv", "content": "a,b\n1,2\n" },           // subdir: written to /workspace/data/input.csv
    { "name": "fixtures/sheet.xlsx", "content": "UEsDBBQ…", "encoding": "base64" } // binary file
  ],
  "limits": {                      // optional per-request overrides; absent → manifest defaults
    "wallTimeMs": 30000, "idleMs": 10000, "cpuMs": 15000,
    "memoryMb": 128, "pids": 64, "outputKb": 512
  },
  "collectOutput": false           // true → persist a pullable RunResult
}

Each files[] entry: name is a relative path under /workspace (subdirs via / are allowed; absolute paths and .. traversal are rejected). content is UTF-8 text unless encoding:"base64" carries arbitrary bytes. See docs/input-files.md.

CodeBodyWhen
202{"jobId","channel":"private-run-<jobId>","status":"queued"}Accepted; enqueued.
400{"error","details":[…]}Invalid body / unknown language or version / invalid base64 / escaping or absolute file path.
413{"error":"Input files too large…"}Total decoded file bytes exceed MAX_FILES_BYTES (default 8 MiB).
429{"error":"Executor at capacity…","retryAfterMs":1000}LLEN(jobs:queue) >= MAX_QUEUE_DEPTH.
Job status, output & languages — response shapes

GET /v1/jobs/:id → 200 JobStatus / 404:

{ "jobId":"<uuid>", "channel":"private-run-<uuid>", "language":"python",
  "version":"3.12", "state":"running", "updatedAtMs":1700000000000 }

state ∈ queued · starting · running · done · killed · error.

GET /v1/jobs/:id/output → 200 RunResult / 404 (single 404 for all absence — callers can't probe which ids exist):

{ "exitCode":0, "signal":null, "timedOut":false, "idleTimedOut":false,
  "truncated":false, "durationMs":312, "stdout":"hello World\n", "stderr":"",
  "artifacts":[{ "name":"plot.png", "mimeType":"image/png", "bytes":20481, "url":"https://…" }],
  "artifactsTruncated":false }

GET /v1/languages → 200:

[ { "language":"python", "version":"3.12", "aliases":["py","py3","python3"], "interactive":true } ]
Output events — emitted on private-run-<jobId>

soketi delivers each event's data as a JSON-encoded string — parse with JSON.parse.

  • stage — { "phase": "queued" | "compiling" | "running" }
  • stdout / stderr — { "chunk": "name? ", "seq": 0 } (reassemble by seq)
  • result (terminal, once) — { exitCode, signal, timedOut, idleTimedOut, truncated, durationMs }
  • artifact — { name, mimeType, bytes, url } (presigned GET URL, no bearer)

Client SDKs

Three published packages cover both sides of the trust boundary:

PackageSideRole
@teovilla/code-runner-sdk-nodeserverTyped CodeRunnerClient over the gateway + soketi channel-auth signing.
@teovilla/code-runner-reactbrowserCodeRunnerProvider + useCodeRunnerJob hook for live output. Token-free.
@teovilla/code-runner-contractsharedThe generated wire contract (types, Zod, channel/event helpers).

Backend (submit + drive):

import { CodeRunnerClient } from "@teovilla/code-runner-sdk-node";

const client = new CodeRunnerClient({ baseUrl: "http://localhost:8080", token: process.env.EXECUTOR_API_TOKEN! });
const job = await client.execute({ language: "python", files: [{ name: "main.py", content: "print(input())" }] });
// subscribe on the browser, then:
await client.start(job.jobId);
await client.sendStdin(job.jobId, "world\n");

Binary + subdir inputs — pass a Buffer and the SDK base64-encodes it for you (encoding:"base64"):

import { readFileSync } from "node:fs";

await client.executeFiles({
  language: "python",
  files: [
    { name: "main.py", content: "import openpyxl; print(openpyxl.load_workbook('data/sheet.xlsx').active.max_row)" },
    { name: "data/sheet.xlsx", data: readFileSync("./sheet.xlsx") }, // binary → base64 transparently
  ],
});

Browser (subscribe to live output):

const { stage, stdout, stderr, result, sendStdin } = useCodeRunnerJob({
  jobId,
  onStdin: (chunk) => fetch(`/api/jobs/${jobId}/stdin`, { method: "POST", body: JSON.stringify({ chunk }) }),
});

The bearer token and soketi APP_SECRET never leave the server — the browser receives output only and routes actions back through your backend. See each package's README for the full API.


Adding a language

Languages are self-contained packages in languages/<lang>-<version>/. Adding one needs zero changes to the worker or the API — only a folder, a manifest.json, and a Dockerfile. A language is defined entirely by image + compile? + run.

// languages/rust-1.83/manifest.json  (a compiled language)
{
  "language": "rust", "version": "1.83", "aliases": ["rs"],
  "image": "executor/rust:1.83", "entrypoint": "main.rs",
  "compile": ["rustc", "-O", "main.rs", "-o", "/workspace/prog"],   // null for interpreted langs
  "run": ["/workspace/prog"],
  "interactive": true,
  "defaultLimits": { "wallTimeMs": 120000, "idleMs": 15000, "cpuMs": 60000, "memoryMb": 512, "pids": 128, "outputKb": 1024 }
}
make rust-image          # build executor/rust:1.83 on the host daemon (or: make build-images)
docker compose up --build  # the manifest loader auto-discovers the new folder at boot
curl localhost:8080/v1/languages -H "Authorization: Bearer $EXECUTOR_API_TOKEN"  # verify

Bundled languages: Python 3.12, Rust 1.83, C and C++ (GCC 14), R 4.4, SQLite 3 — the last two prove the abstraction holds for a non-interactive language and a non-general-purpose tool.


Deployment

Scaling unit: the worker node. A long-lived Go process that claims jobs and launches sandboxes internally (up to WORKER_MAX_SANDBOXES). Autoscale the fleet by LLEN jobs:queue.

  • Fly.io — fly-autoscaler on the Redis llen metric; a four-app reference deploy lives in deploy/fly and is documented in docs/deploy-fly.md.
  • Kubernetes — KEDA's Redis scaler on LLEN jobs:queue, or an HPA custom metric.
  • Extra isolation — set SANDBOX_RUNTIME=runsc to run under gVisor with no code changes; the Sentry intercepts all syscalls.

The API is stateless (runs anywhere, serverless OK). soketi runs as a sidecar or standalone. See docs/scaling.md for the full topology and scale-to-zero caveats.

Environment variables — the full table
VariableDefaultDescription
EXECUTOR_API_TOKENdev-insecure-token-change-meBearer token. Change in prod (openssl rand -hex 32).
REDIS_URLredis://redis:6379Native TCP Redis.
API_PORT8080API listen port.
MAX_QUEUE_DEPTH256/v1/execute → 429 when LLEN(jobs:queue) >= this.
MAX_FILES_BYTES8388608/v1/execute → 413 when total decoded input-file bytes exceed this (8 MiB).
SOKETI_HOST / SOKETI_PORTsoketi / 6001soketi address.
SOKETI_USE_TLSfalseConnect to soketi over TLS.
SOKETI_APP_ID / _APP_KEYcode-runner / code-runner-keyPusher app id & public key.
SOKETI_APP_SECRETcode-runner-secretNever returned by any endpoint, never written to Redis. Change in prod.
WORKER_MAX_SANDBOXES8Max concurrent sandboxes per worker node.
DOCKER_HOSTunix:///var/run/docker.sockDocker endpoint. No Docker-in-Docker.
SANDBOX_RUNTIME(unset = runc)runsc to enable gVisor.
WORKER_WARMUP_MS30000Slot reclaim timeout if /start never arrives.
WORKER_HEARTBEAT_INTERVAL_MS / _TTL_MS5000 / 20000Worker liveness heartbeat.
BUCKET_NAME + AWS_*(unset = artifacts off)S3-compatible artifact storage (MinIO/Tigris/S3/R2).
RUN_RESULT_TTL / PRESIGNED_URL_TTL / ARTIFACT_S3_OBJECT_TTL600s / 24h / 3dRetention TTLs (object TTL must be ≥ presigned).
OTEL_EXPORTER_OTLP_ENDPOINT(unset = off)The telemetry ON switch. Point at an OTLP collector.
OTEL_TRACES_SAMPLER_ARG1.0Sampling ratio. Lower in prod (e.g. 0.05).

The full list (including all OTEL_* and stub variables) is in .env.example.

Observability (bring-your-own OpenTelemetry)

Both API and worker are fully OpenTelemetry-instrumented but ship telemetry-off by default — zero overhead until you set OTEL_EXPORTER_OTLP_ENDPOINT. When enabled, one execution produces a single connected trace spanning the API and the worker (claim → sandbox.create → handshake.wait → compile → run → publish.result). An example collector + Jaeger ships inert under the observability compose profile:

docker compose --profile observability up --build
bash scripts/observability-e2e.sh    # → Jaeger UI: http://localhost:16686

Safety gate

The adversarial abuse suite (internal/worker/abuse_test.go, build tag abuse) is the required gate before any language is added. It drives 7 hostile jobs through the full worker path on real Linux cgroup v2 — OOM kills, CPU throttling, wall-time expiry, idle timeout, PID exhaustion, output truncation, and clean-exit containment.

make python-image
docker run -d -p 6381:6379 redis:7
make abuse

CI (.github/workflows/abuse.yml) runs it on every PR on ubuntu-latest. Repo owners should make "abuse / abuse" a required status check on main so fan-out PRs can't merge unless it's green.


Make targets

TargetDescription
build-imagesBuild all language sandbox images on the host daemon.
up / downBring the local stack up (--build) / tear down (-v).
e2eInteractive end-to-end demo.
artifacts-e2eArtifacts pull-loop end-to-end.
abuseAdversarial safety suite (Docker cgroup v2 + redis:7 on :6381).
testAll unit/integration tests (Go + JS).
contract / contract-checkRegenerate the wire contract / fail on drift.

Repository layout

apps/
  api/        Hono/TypeScript gateway          packages/
  worker/     Go worker + sandbox runner         contract/              wire contract (schema → TS+Zod+Go)
  stub/       interactive E2E driver             code-runner-sdk-node/  server SDK
  docs/       Fumadocs documentation site        code-runner-react/     browser SDK
internal/     Go packages (runner, session,    languages/    one folder per language (manifest + Dockerfile)
              jobstore, publisher, reaper, …)  profiles/     seccomp profile
deploy/fly/   Fly.io reference deploy          observability/ OTel collector config

Contributing

Contributions are welcome. The one hard rule before merging a new language or a worker change: the abuse suite must be green. See Safety gate. Run make test and make contract-check before opening a PR.

License

MIT — self-hostable, no strings attached.