Skip to content

enixCode/light-runner

Repository files navigation

light-runner banner

light-runner

Run untrusted code in hardened Docker containers from Node.js.
Domain-agnostic: exit code, logs, extracted files. Nothing else.

latest release npm version license

Website - Install - Quick start - Extract - Security - Roadmap - Testing


Ecosystem

light-runner is the execution primitive in a family of small, composable tools. Each project does one thing and hands off the rest.

Project Responsibility Status
light-runner Spawn one container, return exit code + files this repo
light-run CLI + HTTP wrapper around light-runner shipped
light-process DAG orchestration, retries, fan-out shipped

Each layer is an independent npm package. Use light-runner alone when you just need to run code in a sandbox; pick the higher layers when you want scheduling or an HTTP surface.


Install

npm install light-runner

Requirements

  • Node.js >= 22
  • A running Docker daemon (Docker Desktop on macOS/Windows, dockerd on Linux)
  • Optional: gVisor (runsc) for kernel isolation

Quick start

import { DockerRunner } from 'light-runner';

const runner = new DockerRunner({ memory: '512m', cpus: '1' });

const execution = runner.run({
  image: 'python:3.12-alpine',
  entrypoint: 'python main.py',
  dir: './my-project',
  input: { task: 'compute', n: 20 },
  timeout: 30_000,
  extract: [{ from: '/app/result.json', to: './out' }],
});

const result = await execution.result;

result.success    // true if exitCode === 0, not cancelled, not timed out
result.exitCode   // container exit code
result.duration   // ms
result.cancelled  // true if cancel() / abort signal
result.extracted  // [{ from, to, status, bytes? }, ...] if extract was set

Need structured output back? Have the container write a file and extract it:

// Inside the container:
fs.writeFileSync('/app/result.json', JSON.stringify({ fib: 6765 }))

// On the host:
const result = await execution.result;
const data = JSON.parse(fs.readFileSync('./out/result.json', 'utf8'));

Your container writes whatever files it wants, you pull them out.

What happens under the hood:

  1. A named Docker volume is created (light-runner-<uuid>).
  2. The folder at dir is streamed into the volume via a throwaway Alpine seeder, skipping .git, node_modules, dist, build, .next, .cache, .turbo, coverage, and all symlinks.
  3. Your image runs with the volume mounted at workdir (default /app), strict isolation flags, and a PID / memory / CPU cap.
  4. On exit-code 0, each extract entry is streamed out via tar. On non-zero exit, extract is skipped.
  5. The volume is destroyed, success or not.

Setup steps (cached builds)

When your container needs system packages, language deps, or any other one-time preparation, pass them as run: string[]. Each entry becomes a RUN <step> in a generated Dockerfile, the derived image is tagged light-runner-cache:<sha256(image + run)>, and a second identical request hits the cache instead of rebuilding.

runner.run({
  image: 'python:3.11-alpine',
  run: [
    'apk add --no-cache curl',
    'pip install --no-cache-dir requests',
  ],
  entrypoint: 'python main.py',
  dir: './my-project',
});

Behind the scenes:

  1. The runner hashes (image, run) -> a tag like light-runner-cache:5b3c1a....
  2. If that tag already exists locally, the build is skipped and the run starts immediately.
  3. Otherwise the runner builds a virtual Dockerfile (no file on disk; tar-streamed to the daemon via docker.buildImage), labels the result light-runner.kind=cache, and uses it as the base for the run.
  4. The user's entrypoint then runs against that cached image - your actual workload starts on top of an already-prepared FS.

Threat model

The build itself runs in the Docker daemon's builder (BuildKit by default), which does NOT inherit the runtime sandbox flags (CapDrop, no-new-privileges, PidsLimit, gVisor runtime, isolated network). Treat run[] as operator-trusted input. Never pass user-supplied strings here.

If you need every byte the workload runs to be sandboxed, bake your setup into a custom image with a Dockerfile you control and pass it via image. run[] is for the convenient case where the operator already trusts the steps.

Validation rules

Each step is validated before any build attempt; violations throw LightRunnerError with code INVALID_RUN_STEP:

Rule Why
no newlines, carriage returns, null bytes would inject a second Dockerfile instruction on the next line
no trailing backslash Dockerfile line continuation - would absorb the following RUN prefix
no leading -- blocks BuildKit RUN --mount=type=bind, --network=host, --security=insecure, --device=

These three rules are sufficient against Dockerfile-level injection; the underlying primitive is "we control the RUN prefix, the user only controls the command portion".

Cache lifecycle

  • Cache images persist across runs and across host restarts (they are just regular Docker images).
  • A failed build leaves no cache image tagged - the run rejects with BUILD_FAILED, you fix the step, and retry.
  • DockerRunner.cleanupOrphanCache({ maxAgeMs }) removes cache images that have not been used for maxAgeMs (default 7 days). "Used" means a run consumed the image (build or cache hit), so an old-but-active cache survives. The last-used timestamp is tracked in <stateDir>/cache-usage.json; when no entry exists the function falls back to the image Created time. Images currently referenced by a running container are skipped by the daemon's refcount.
  • Floating tags (python:3.11) hash on the literal string, not the resolved digest. If you need to pick up upstream updates, either pin with image@sha256:... or sweep the cache and let the next run rebuild.

Extract

Extract is the only output channel for files. It streams disk-to-disk via tar, never buffering in Node RAM.

extract: [
  { from: '/app/dist',       to: './out' },  // folder, recursive
  { from: '/app/report.pdf', to: './out' },  // single file
  { from: '/app/nope',       to: './out' },  // missing -> reported, run still succeeds
]

Folder vs. file semantics (rsync-like)

  • Folder from: the contents of the folder land directly in to (no basename wrap). Equivalent to rsync -a from/ to/ or cp -r from/. to/. All subdirectories and files are included recursively.
  • File from: the file lands as to/basename(from).
  • to is always a destination directory, auto-created via fs.mkdirSync(to, { recursive: true }).
  • Symlinks encountered during extract are skipped (security - a malicious container could craft a symlink whose target path exists on the host).
  • Per-entry cap: 1 GiB. Above that, the entry is reported as error, the run still succeeds.
  • Extract only runs if exitCode === 0 and the run was not cancelled. Failed runs leave extracted undefined.

Example:

extract: [
  { from: '/app/dist',    to: './out' },  // /app/dist/a.js -> ./out/a.js
  { from: '/app/dist/',   to: './out' },  // trailing slash, same result
  { from: '/app/report.pdf', to: './out' }, // -> ./out/report.pdf
]

Result

Each extract entry produces one ExtractResult:

status meaning
'ok' archived and extracted. bytes = archive size.
'missing' from does not exist in the container
'error' cap exceeded, path traversal, collision, etc.

A missing or errored entry never fails the run - the consumer inspects result.extracted and decides what to do.


API

v0.10 transport change. Internals now talk to the daemon through dockerode instead of shelling out to the docker CLI. Public behavior is unchanged for run() / Execution.result / extract. Breaking: DockerRunner.isAvailable, DockerRunner.cleanupOrphanVolumes, DockerRunner.cleanupOrphanStates, Execution.pause, and Execution.resume are now async. New API: DockerRunner.reapOrphans() (sweep label-tagged stale containers + volumes) and a structured LightRunnerError class with LightRunnerErrorCode.

class DockerRunner {
  constructor(options?: RunnerOptions);
  run(request: RunRequest): Execution;
  static isAvailable(): Promise<boolean>;
  static cleanupOrphanVolumes(): Promise<number>;

  // experimental - see "Experimental features" section below
  static attach(id: string): Execution | null;
  static list(): RunState[];
  static cleanupOrphanStates(): Promise<number>;
  static cleanupOldStates(maxBytes?: number): number;
  static reapOrphans(): Promise<{ containers: number; volumes: number }>;
}

class Execution {
  readonly id: string;
  readonly result: Promise<RunResult>;
  cancel(): void;
  get cancelled(): boolean;

  // experimental - see "Experimental features" section below
  stop(options?: { signal?: string; grace?: number }): Promise<void>;
  pause(): Promise<void>;
  resume(): Promise<void>;
}

class LightRunnerError extends Error {
  readonly code: LightRunnerErrorCode;
  readonly dockerOp?: string;
  readonly containerId?: string;
}

type LightRunnerErrorCode =
  | 'DOCKER_UNREACHABLE'
  | 'VOLUME_CREATE_FAILED'
  | 'CONTAINER_START_FAILED'
  | 'SEED_FAILED'
  | 'EXTRACT_FAILED'
  | 'BUILD_FAILED'
  | 'INVALID_RUN_STEP'
  | 'NETWORK_CONNECT_FAILED';

// Run-state helpers around the state dir (inspect + delete):
function listStates(): RunState[];
function readState(id: string): RunState | null;
function deleteState(id: string): void; // idempotent: a missing id is a no-op

interface RunState {
  id: string;
  container: string;
  volume: string;
  image: string;
  workdir: string;
  entrypoint?: string;
  timeout?: number;
  extract?: ExtractSpec[];
  startedAt: string;
  finishedAt?: string;
  status: 'running' | 'exited' | 'cancelled' | 'failed';
  exitCode?: number;
  durationMs?: number;
  cancelled?: boolean;
}

Experimental features (added in v0.9): RunRequest.detached, DockerRunner.attach / list / cleanupOrphanStates, and Execution.stop / pause / resume. They pass the test suite and the manual demo, but they are new and cover edge cases (signal forwarding across runtimes, TCP connection behaviour during pause, cross-host state dir semantics) that may surface bugs. If anything does not work as documented, please open an issue: github.com/enixCode/light-runner/issues.

Full type signatures live in src/types.ts.

RunRequest

field meaning
image Docker image reference (required)
entrypoint shell command to run via sh -c (overrides image ENTRYPOINT+CMD)
run string[] of RUN setup steps, baked into a cached image (see Setup steps)
dir host folder copied into the container workdir
input any JSON value, piped to container stdin
timeout ms before the container is SIGKILLed (default 20 min)
networks string[]: first = primary (NetworkMode), rest connected after create. undefined = isolated bridge, ['none'] = no net
env Record<string, string> (invalid POSIX names dropped)
workdir default /app
signal optional AbortSignal to cancel the run
onLog callback fired per stdout/stderr line
extract ExtractSpec[] - pull files/folders out after success

RunResult

field meaning
success exitCode === 0 && !cancelled && !timedOut
exitCode container exit code
duration milliseconds
cancelled true if cancel() called or signal aborted
extracted ExtractResult[], present only if extract was passed

RunnerOptions

field default meaning
memory 512m --memory value (cgroup hard cap)
cpus 1 --cpus value (scheduler share)
runtime runc runc | runsc (gVisor) | kata
gpus - 'all' | number | string, shell-safe checked
noNewPrivileges true block setuid escalation inside the container

Reliability

Failures talk to you in a structured way, and stale resources do not pile up.

  • Daemon pre-flight. pingDaemon() runs before the first container spawn and races dockerode's ping() against a 5 s deadline (DOCKER_PING_TIMEOUT_MS). If the daemon is unreachable you get a LightRunnerError with code DOCKER_UNREACHABLE instead of an opaque socket error.

  • Structured errors. Internal docker calls that fail throw a LightRunnerError with one of these codes:

    • DOCKER_UNREACHABLE - daemon ping or connect failed
    • VOLUME_CREATE_FAILED - per-run volume could not be created
    • CONTAINER_START_FAILED - container could not be created or started
    • SEED_FAILED - host folder could not be streamed into the volume
    • EXTRACT_FAILED - artifact streaming out of the container failed
    • BUILD_FAILED - run[] setup build failed (transport or non-zero RUN step)
    • INVALID_RUN_STEP - a run[] entry violated the validation rules
    • NETWORK_CONNECT_FAILED - an extra networks[] entry could not be attached (e.g. the network does not exist)

    Each error carries an optional dockerOp (which docker call was in flight) and containerId (when known) for log correlation.

  • Orphan reaping. DockerRunner.reapOrphans() lists every light-runner-* container and volume tagged with the library's label. Containers idle or exited longer than LIGHT_RUNNER_REAP_AGE_MS (default 5 min) are removed; their associated volumes are removed unconditionally (the daemon refuses to remove a volume that is still in use by a running container). Returns { containers, volumes } counts. Safe to call from a cron, a process-shutdown hook, or a sibling watchdog.

  • Volume sweeping. DockerRunner.cleanupOrphanVolumes() removes light-runner-* volumes, skipping any created within the last LIGHT_RUNNER_REAP_AGE_MS (default 5 min) so it cannot yank a volume out from under a concurrent run that has not yet mounted it. Returns the count removed.

  • State-file GC. Every run writes one JSON file per run id under the state dir (both attached and detached since v0.15). DockerRunner.cleanupOldStates(maxBytes?) caps that directory: once its total size exceeds maxBytes (default LIGHT_RUNNER_STATE_MAX_BYTES, or 50 MiB) it deletes terminal states (exited / cancelled / failed) oldest-first until back under budget. running states are never removed. Returns the count deleted. This is distinct from cleanupOrphanStates(), which only reconciles running -> failed and deletes nothing. Synchronous (pure filesystem); call it on boot or a schedule.

  • Attached vs detached state. Attached (synchronous) runs are persisted purely for observability (listStates()) and post-mortem reconciliation: they cannot be resumed live (AutoRemove tears the container down on exit, and onLog output is not replayed on attach). If an attached launcher dies mid-run, the container is auto-removed and the state stays running until cleanupOrphanStates() marks it failed.


Security model

Defaults (always on - never opt-out):

  • Capabilities dropped: NET_RAW, MKNOD, SYS_CHROOT, SETPCAP, SETFCAP, AUDIT_WRITE
  • no-new-privileges security option - no setuid escalation inside the container
  • --pids-limit 100 - fork-bomb protection
  • --memory 512m / --cpus 1 by default - cgroup-enforced, tunable via RunnerOptions
  • Isolated bridge network by default, with inter-container ICC disabled; ['none'] fully disconnects. Listing several networks in networks[] attaches the container to each (operator-trusted opt-in, widens reachability)
  • Symlinks in dir are filtered out at seed time - a host link cannot appear in the container
  • Path traversal in extract (..) is rejected before any container spawns
  • Extract cap - 1 GiB per entry, enforced container-side via du -sb and streaming byte-count

What this doesn't cover: kernel exploits, runc CVEs, side-channel attacks. For genuinely hostile code, combine with a stronger runtime:

  • { runtime: 'runsc' } - gVisor, user-space syscall interception. Tested, recommended default for hostile workloads. ~10-30% I/O overhead.
  • { runtime: 'kata' } - Kata Containers, full VM-level isolation. Option is plumbed through (passed straight to docker's Runtime host config) but not yet validated in our test matrix - if you run it in production, please open an issue with results.

Secrets

env vars go to docker run --env, which makes them visible in docker inspect and Docker metadata. That's fine in most setups (Docker socket access is already root-equivalent on the host), but for sensitive material prefer:

  • input (stdin) - ephemeral, not in metadata, not in docker inspect. Your container reads it via sys.stdin.read() / process.stdin.
  • A bind mount to /run/secrets/<name> - the Docker-native file-based secrets pattern (compose has a secrets: block for this). Not managed by light-runner, the consumer wires it.

gVisor hardening

gVisor (runsc) intercepts syscalls in user-space and presents a much smaller attack surface than sharing the host kernel. Trade-off: ~10-30% slower on I/O.

Install on Linux / WSL2 (gVisor does not run natively on macOS or Windows; use the WSL2 backend of Docker Desktop):

(
  set -e
  ARCH=$(uname -m)
  URL=https://storage.googleapis.com/gvisor/releases/release/latest/${ARCH}
  wget ${URL}/runsc ${URL}/runsc.sha512 \
       ${URL}/containerd-shim-runsc-v1 ${URL}/containerd-shim-runsc-v1.sha512
  sha512sum -c runsc.sha512 -c containerd-shim-runsc-v1.sha512
  rm -f *.sha512
  chmod a+rx runsc containerd-shim-runsc-v1
  sudo mv runsc containerd-shim-runsc-v1 /usr/local/bin
)
sudo /usr/local/bin/runsc install
sudo systemctl reload docker

latest is a rolling channel - gVisor ships frequent date-stamped releases (release-YYYYMMDD.N), no semantic version. Check the current pointer with runsc --version after install.

Then:

const runner = new DockerRunner({ runtime: 'runsc' });

Observability (OpenTelemetry)

light-runner emits OpenTelemetry spans for every container run. The library only depends on @opentelemetry/api (declared as an optional peer dependency), so it adds no runtime cost when no SDK is registered.

Span tree

For an attached or detached run via runner.run(...):

docker.run                       (root)
  + container.setup
    + container.build_cache      (only when request.run[] is set)
    + container.create_volume    (always)
    + container.seed             (only when request.dir is set)
    + container.network_ensure   (only when the primary network is default/empty)
    + container.create           (always)
    + container.network_connect  (only when request.networks lists extra networks)
  + container.exec               (start + stream + wait for exit)
  + container.extract            (only when request.extract is set and exit code is 0)

For a re-attach via DockerRunner.attach(id):

docker.reattach                  (root; waits for the existing container, then extract + cleanup)

The network module also emits its own spans (network.create, network.connect, network.delete, network.exists, network.cleanup), each as a root or as a child of whatever span is active at the call site.

The root docker.run and docker.reattach spans set status to ERROR when the container exits non-zero AND the run was not cancelled. Cancelled runs leave the status UNSET (per OTel spec, instrumentation libraries SHOULD NOT mark cancellations as errors).

Attribute conventions

Prefix Meaning Examples
container.* OTel semantic conventions aligned (Honeycomb / Datadog / New Relic surface these natively in container panels) container.id, container.image, container.image.name, container.image.tag, container.exit_code, container.duration_ms, container.network
lightrunner.* light-runner-specific, no OTel semconv equivalent lightrunner.success, lightrunner.cancelled, lightrunner.detached, lightrunner.timeout_ms, lightrunner.has_setup_run, lightrunner.has_extract, lightrunner.has_input, lightrunner.extract.count, lightrunner.network.*, lightrunner.seed_dir, lightrunner.run_steps

Enabling traces

Light-runner emits spans through @opentelemetry/api. To actually export them, register an SDK in your application (the consumer of light-runner). Minimal local setup with the ConsoleSpanExporter:

import { NodeSDK } from '@opentelemetry/sdk-node';
import { ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { DockerRunner } from 'light-runner';

const sdk = new NodeSDK({
  spanProcessors: [new SimpleSpanProcessor(new ConsoleSpanExporter())],
});
sdk.start();

const runner = new DockerRunner();
await runner.run({ image: 'alpine:3.19', entrypoint: 'echo hi' }).result;

await sdk.shutdown();

For production, replace the ConsoleSpanExporter with an OTLP exporter (@opentelemetry/exporter-trace-otlp-http) that points at an OpenTelemetry Collector. See examples/demo-otel.ts (npm run demo:otel) for a runnable version.

Trace context propagation

When a parent context is active (for example because the calling code is itself running inside a span), docker.run is created as a child automatically via @opentelemetry/api's active context. This is how light-run and light-process build a full workflow > node > docker.run > container.* tree across the three processes without any manual header threading.

Opt-out

To disable instrumentation, simply do not register an SDK. The api package falls back to its built-in NoopTracer and every tracer.startSpan(...) call resolves to a stub with no measurable overhead.


Project layout

light-runner/
  src/
    index.ts             public exports
    DockerRunner.ts      thin class shell: constructor + run() + static forwarders
    Execution.ts         cancellable handle: cancel, stop, pause, resume
    types.ts             RunRequest, RunResult, RunnerOptions, ExtractSpec, ...
    createOptions.ts     pure builder of dockerode ContainerCreateOptions
    docker.ts            lazy dockerode proxy + pingDaemon + createContainerWithPull
    network.ts           CRUD primitives + IPAM (createNetwork / connectNetwork / deleteNetwork / networkExists / cleanupOrphanNetworks + buildIpamConfig)
    errors.ts            LightRunnerError + structured error codes
    state.ts             atomic JSON state per id for detached runs
    constants.ts         caps, limits, names, defaults
    telemetry.ts         OTel tracer + withSpan + parseImageRef / imageAttributes
    build.ts             run[] cache: validateRunStep + buildCacheImage + cleanupOrphanCache
    cacheUsage.ts        side index of cache last-used timestamps
    runner/
      attached.ts        executeAttached + private runContainer (attach+start+wait)
      detached.ts        executeDetached (sync writeState, polling, finally cleanup)
      reattach.ts        attachExecution(id) resume path
      setup.ts           shared pre-flight (build_cache, volume, seed, network, create)
      operator.ts        statics impl (isAvailable, cleanupOrphan*, reapOrphans, list)
      wait.ts            containerExists, waitForContainer, waitForDetachedExit
      context.ts         ExecuteCtx, RunContext (private types)
      stdio.ts           drainLines (shared by attached/detached)
    volume/
      index.ts           create / destroy / cleanup-orphans
      seed.ts            seed dir into volume via tar stream
      extract.ts         extract files out via tar with 1 GiB cap
      seeder.ts          shared throwaway-alpine helpers + shellQuote
  test/
    unit/                pure, no Docker
      createOptions.test.ts   security flag matrix
      state.test.ts           atomic write + listStates
      network.test.ts         IPAM mapping (buildIpamConfig)
      telemetry.test.ts       parseImageRef + imageAttributes
    e2e/                 Docker required
      attach.test.ts          re-attach to detached runs
      cancel-race.test.ts     cancel race conditions during inspect polling
      detached.test.ts        detached lifecycle
      detached-wait.test.ts   detached wait sentinel (no early resolve)
      limits.test.ts          memory / cpu / pids / extract caps
      realworld.test.ts       image-pull + real-world workloads (Python, Node, Ruby, Go)
      run.test.ts             run[] cache integration + BUILD_FAILED paths
      runner.test.ts          lifecycle, cancel, timeout, adversarial
      stop-pause.test.ts      stop / pause / resume
      volume.test.ts          seed + extract round-trips
  Dockerfile.test          test harness image
  docker-compose.test.yml  sibling-container test runner
  dist/                    compiled output (gitignored, npm-published)

Testing

npm run test:unit    # no Docker, <500ms
npm run test:e2e     # Docker required
npm test             # all
npm run test:docker  # all, inside a disposable container (see below)

Running the full suite in a container

npm run test:docker uses docker-compose.test.yml to spin up node:22-alpine, bind-mount the repo, and mount the host Docker socket. dockerode talks to the host daemon directly through the socket; spawned containers land as siblings on the host, not nested (no Docker-in-Docker, no nested daemon, no docker CLI required inside the test image).

npm run test:docker

Prerequisites:

  • Docker + docker compose plugin on the host
  • On Windows, Docker Desktop with WSL2 backend

Roadmap

  • Folder ignore option: extend the hardcoded excludes when seeding a directory.
  • Override the 1 GiB extract cap via RunnerOptions.maxExtractBytes.

Detached runs, attach, stop, and pause/resume shipped in v0.9 and moved to dockerode in v0.10. See the API section above for the current surface.

Roadmap items are not public API and are subject to change.


License

MIT

About

Run isolated Docker containers from Node.js

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors