Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

229 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

code-runner — run untrusted code in a hardened, interactive sandbox

MIT License CI Abuse safety gate sdk-node on npm Go 1.26 Node 22+

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 default network=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 stdin Write to a running process mid-execution and watch stdout/stderr stream back chunk by chunk over WebSockets.
⏱️ Three independent clocks Wall, idle, and CPU time bound every sandbox. Any clock firing kills it unconditionally — no leaks, ever.
🧩 Polyglot by design A 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 folder Each language is a manifest.json + Dockerfile, auto-discovered at boot. Zero changes to Go or the API.
☁️ Stateless & scalable Autoscale the worker fleet by Redis queue depth. Scale-to-zero friendly. MIT, self-hostable, BYO OpenTelemetry.

Tip

There is a full documentation site under apps/docs (Next.js + Fumadocs). Run it locally with pnpm --filter docs dev and open http://localhost:3000.


🏗️ 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.

flowchart TD
    App["🧑‍💻 Upstream App<br/>your backend"]
    API["API · Hono gateway<br/>stateless · N replicas"]
    Redis[("Redis<br/>queue · pub-sub · metadata")]
    subgraph Worker["Worker node · Go · long-lived"]
        direction LR
        S1["Sandbox"]
        S2["Sandbox"]
        S3["… up to<br/>WORKER_MAX_SANDBOXES"]
    end
    Soketi["soketi · WebSocket fan-out<br/>output only"]
    Client["🌐 Browser / Client"]

    App -- "Bearer token · POST /v1/execute" --> API
    API -- "LPUSH jobs:queue" --> Redis
    Redis -- "BRPOP · SUBSCRIBE stdin:jobId" --> Worker
    Worker -- "Pusher HTTP trigger" --> Soketi
    Soketi -- "channel private-run-jobId" --> Client
    Client -. "stdin / control · via your backend → API" .-> App
Loading

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.

Important

The worker requires native TCP Redis (redis:///rediss://) — it uses blocking BRPOP and SUBSCRIBE. Serverless REST-only Redis (e.g. Upstash's REST tier) works for the API but not the worker. See docs/redis-constraint.md.


🚀 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  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).

sequenceDiagram
    autonumber
    participant U as Client (your backend)
    participant API as API (Hono)
    participant R as Redis
    participant W as Worker (Go)
    participant SK as soketi

    U->>API: POST /v1/execute
    API->>R: write spec/status · LPUSH jobs:queue
    API-->>U: 202 · jobId + channel
    W->>R: BRPOP jobs:queue (claim)
    W->>R: SUBSCRIBE stdin:id + ctrl:id
    Note over W: parked at the start gate
    U->>SK: subscribe private-run-id
    U->>API: POST /v1/jobs/:id/start
    API->>R: PUBLISH ctrl:id (start)
    W->>W: sandbox.Start() then run
    W->>SK: stdout "name? "
    SK-->>U: stdout event
    U->>API: POST /v1/jobs/:id/stdin (chunk)
    API->>R: PUBLISH stdin:id
    R-->>W: chunk into process stdin
    W->>SK: stdout "hello World"
    SK-->>U: stdout event
    W->>SK: result · exitCode 0
    SK-->>U: result event (terminal)
    W->>W: cleanup · free slot · unsubscribe
Loading

The three clocks

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

Clock Limit Fires when… Result field
Wall wallTimeMs total lifetime exceeds the limit timedOut
Idle idleMs no output and no stdin for the window (resets on activity) idleTimedOut
CPU cpuMs cumulative cgroup CPU time exceeds the limit timedOut

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.

Method Path Purpose
GET /health Readiness probe (no auth).
POST /v1/execute Submit code → 202 {jobId, channel, status}.
POST /v1/jobs/:id/start The start-handshake signal.
POST /v1/jobs/:id/stdin Write a chunk to the process stdin.
POST /v1/jobs/:id/stdin/close Signal EOF (Ctrl-D).
POST /v1/jobs/:id/kill SIGKILL the sandbox.
GET /v1/jobs/:id Poll job status.
GET /v1/jobs/:id/output Pull persisted output (needs collectOutput).
GET /v1/languages List discovered languages.
POST /v1/channel-auth Optional soketi channel-auth helper (ENABLE_CHANNEL_AUTH=true).
POST /v1/execute — request & responses
// Request body
{
  "language": "python",            // name or alias: "py", "rust", "rs", "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.

Code Body When
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/:id200 JobStatus / 404:

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

statequeued · starting · running · done · killed · error.

GET /v1/jobs/:id/output200 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/languages200:

[ { "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:

Package Side Role
@teovilla/code-runner-sdk-node server Typed CodeRunnerClient over the gateway + soketi channel-auth signing.
@teovilla/code-runner-react browser CodeRunnerProvider + useCodeRunnerJob hook for live output. Token-free.
@teovilla/code-runner-contract shared The 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, R 4.4, SQLite 3 — the last two prove the abstraction holds for a non-interactive language and a non-general-purpose tool.

Warning

Before a new language merges, its image must survive the abuse suite — see Safety gate.


☁️ 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.iofly-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
Variable Default Description
EXECUTOR_API_TOKEN dev-insecure-token-change-me Bearer token. Change in prod (openssl rand -hex 32).
REDIS_URL redis://redis:6379 Native TCP Redis.
API_PORT 8080 API listen port.
MAX_QUEUE_DEPTH 256 /v1/execute429 when LLEN(jobs:queue) >= this.
MAX_FILES_BYTES 8388608 /v1/execute413 when total decoded input-file bytes exceed this (8 MiB).
SOKETI_HOST / SOKETI_PORT soketi / 6001 soketi address.
SOKETI_USE_TLS false Connect to soketi over TLS.
SOKETI_APP_ID / _APP_KEY code-runner / code-runner-key Pusher app id & public key.
SOKETI_APP_SECRET code-runner-secret Never returned by any endpoint, never written to Redis. Change in prod.
WORKER_MAX_SANDBOXES 8 Max concurrent sandboxes per worker node.
DOCKER_HOST unix:///var/run/docker.sock Docker endpoint. No Docker-in-Docker.
SANDBOX_RUNTIME (unset = runc) runsc to enable gVisor.
WORKER_WARMUP_MS 30000 Slot reclaim timeout if /start never arrives.
WORKER_HEARTBEAT_INTERVAL_MS / _TTL_MS 5000 / 20000 Worker 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_TTL 600s / 24h / 3d Retention TTLs (object TTL must be ≥ presigned).
OTEL_EXPORTER_OTLP_ENDPOINT (unset = off) The telemetry ON switch. Point at an OTLP collector.
OTEL_TRACES_SAMPLER_ARG 1.0 Sampling 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

Target Description
build-images Build all language sandbox images on the host daemon.
up / down Bring the local stack up (--build) / tear down (-v).
e2e Interactive end-to-end demo.
artifacts-e2e Artifacts pull-loop end-to-end.
abuse Adversarial safety suite (Docker cgroup v2 + redis:7 on :6381).
test All unit/integration tests (Go + JS).
contract / contract-check Regenerate 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.

About

Run untrusted code in a hardened, live sandbox.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages