Documentation

Sandboxing for untrusted commands on Kubernetes.

Shiitake is an HTTP server that fans commands out to a pool of resident, resource-bounded worker containers. Each command runs in its own worker, which resets to a clean slate between commands β€” so there is no state bleed from one caller to the next. The server is the only ingress; workers never bind a public port.

Shiitake is generic: it has no knowledge of any particular application. You bring the toolchain image, drop in the worker binary as its entrypoint, and the server dispatches commands to the pool.

Overview

The project is a small Rust workspace plus two thin clients:

ComponentRole
shiitake-serverThe control plane. An axum HTTP API, a WebSocket dispatcher, the worker pool, the Kubernetes OOM probe, and OpenTelemetry. Owns the capture-file layout and serves range reads. The only ingress. Ships as a container image.
shiitake-workerRuns one command at a time in its own process group, redirects output to capture files, reports resource usage, and resets between commands. Drops into your toolchain image as the entrypoint.
shiitake-server-apiLibrary. The HTTP request/response types β€” the contract between the server and any client. Pure types, no transport.
shiitake-worker-apiLibrary. The server↔worker wire frames plus the on-disk capture layout. The worker depends only on this.
clients/shiitake-rsAsync Rust client over the HTTP API (reqwest).
clients/shiitake-pyPython client over the HTTP API (httpx).

Architecture

One server process is the single entry point. It accepts POST /api/v1/exec, picks an idle worker from the pool, and hands the command to it over a WebSocket on the dispatch listener. The worker runs the command, streams stdout/stderr straight to capture files on a shared volume, reports the result, and then resets its sandbox before serving the next command.

Workers never bind a port β€” they dial the dispatcher, at whatever SHIITAKE_DISPATCH_URL says, presenting a bearer token on the upgrade. That address is the only thing that changes between running the server and the workers in one Pod and running them in two.

flowchart TB
  client["Client"]

  subgraph cluster["Kubernetes cluster"]
    api["Kubernetes API"]
    subgraph pod["Shiitake Pod(s)"]
      server["server"]
      subgraph workers["worker pool"]
        w0["worker-0"]
        w1["worker-1"]
        wn["worker-N"]
      end
      vol[("shared capture volume")]
    end
  end

  client -->|"exec"| server
  server -->|"dispatch (ws + bearer)"| w0
  server --> w1
  server --> wn
  server -.->|"get pods"| api
  w0 -->|"write"| vol
  w1 --> vol
  wn --> vol
  server -.->|"read"| vol
      
POST /api/v1/exec  β†’  Server (only ingress)  β†’  Worker pool  β†’  one command per worker
                                                      ↑               ↓ resets between commands
                                                      └───── re-advertised idle β”€β”€β”€β”€β”€β”˜

Resident workers & the clean slate

A worker connects once and serves commands in a loop. Between commands it resets its sandbox to reproduce the effect of a container teardown without the cost of one:

If a reset cannot be trusted clean β€” a process won't die, a scratch clear fails, or an IPC object survives β€” the worker recycles into a fresh container rather than serving on a dirty sandbox. Staying resident avoids the kubelet CrashLoopBackOff that per-command container exits would otherwise incur.

Defense in depth: set SHIITAKE_RESTART_AFTER=N to make the worker exit (and the container recycle) after every N commands, bounding anything the in-process reset can't scrub. 0 = stay resident; 1 = a fresh container for every command.

Output capture

The worker redirects the command's stdout/stderr file descriptors straight into per-stream capture files β€” the kernel writes to disk, so neither the worker nor the server buffers output in memory. The server reads it back with HTTP Range support. Storage is bounded only by the volume; output sizes are exported as metrics so a runaway command is observable rather than silently truncated.

Local quickstart

Run the server and a worker locally with Cargo, then dispatch a command:

# Terminal 1 β€” the server (the only ingress)
SHIITAKE_AUTH_TOKEN=dev-token SHIITAKE_DISPATCH_TOKEN=dev-dispatch \
  SHIITAKE_CAPTURE_ROOT=/tmp/capture \
  cargo run --bin shiitake-server

# Terminal 2 β€” one worker, joining the pool
# (SHIITAKE_DISPATCH_URL defaults to ws://127.0.0.1:8090/dispatch)
SHIITAKE_WORKER_ID=worker-0 SHIITAKE_DISPATCH_TOKEN=dev-dispatch \
  SHIITAKE_CAPTURE_ROOT=/tmp/capture \
  cargo run --bin shiitake-worker

# Terminal 3 β€” wait for the pool, then dispatch a command
# (-f fails on the 503 /ready returns while no worker has registered)
until curl -sf localhost:8080/api/v1/ready >/dev/null; do sleep 1; done
curl -s -H "Authorization: Bearer dev-token" \
  -X POST localhost:8080/api/v1/exec \
  -d '{"command": "echo hello from a sandboxed worker"}'

Running outside Kubernetes, the container-OOM probe and per-command container recycle are inert β€” the worker simply resets in-process. This is the right setup for development and for the in-process integration tests.

Installation

Shiitake is distributed as two OCI images published to GitHub Container Registry:

ArtifactImage
Serverghcr.io/tenzailabs/shiitake-server
Workerghcr.io/tenzailabs/shiitake-worker β€” a minimal image holding just the static binary

The server image is a minimal Alpine wrapping a static musl binary; it makes outbound TLS calls to the Kubernetes API (for OOM detection) but never executes user commands itself. The worker is shipped only as a binary so you can layer it into whatever toolchain image your commands need β€” see building a custom worker image.

Deploying on Kubernetes

The simplest deployment is a single Pod running one server container and N worker containers. Two things must be shared across them:

Server and workers can also run as separate Pods, which is what lets pod-scoped controls differ between them.

A minimal single-Pod spec looks like this:

# one server + workers in one Pod, sharing netns + a capture volume
spec:
  serviceAccountName: shiitake        # bound to the pod-reader Role below
  containers:
    - name: server
      image: ghcr.io/tenzailabs/shiitake-server
      env:
        - name: SHIITAKE_AUTH_TOKEN
          valueFrom: { secretKeyRef: { name: shiitake, key: token } }
        - name: SHIITAKE_DISPATCH_TOKEN
          valueFrom: { secretKeyRef: { name: shiitake, key: dispatch-token } }
        - name: SHIITAKE_CAPTURE_ROOT
          value: /capture
        - name: POD_NAME
          valueFrom: { fieldRef: { fieldPath: metadata.name } }
        - name: POD_NAMESPACE
          valueFrom: { fieldRef: { fieldPath: metadata.namespace } }
      readinessProbe:                             # gated on the pool: 503 until a worker registers
        httpGet: { path: /api/v1/ready, port: 8080 }
      livenessProbe:                              # process-level only; an empty pool is not a restart
        httpGet: { path: /api/v1/health, port: 8080 }
      volumeMounts:
        - { name: capture, mountPath: /capture }
    - name: worker-0
      image: your-registry/your-worker:latest   # your toolchain + the worker binary
      restartPolicy: Always                       # recycle this container on its own
      env:
        - { name: SHIITAKE_WORKER_ID,   value: worker-0 }
        - name: SHIITAKE_DISPATCH_TOKEN
          valueFrom: { secretKeyRef: { name: shiitake, key: dispatch-token } }
        - { name: SHIITAKE_CAPTURE_ROOT, value: /capture }
        - { name: SHIITAKE_RESET_PATHS,  value: "/tmp,/var/tmp,/dev/shm" }
      volumeMounts:
        - { name: capture, mountPath: /capture }
  volumes:
    - { name: capture, emptyDir: {} }

The per-container restartPolicy: Always lets each worker recycle independently when it hits its SHIITAKE_RESTART_AFTER quota or fails a reset. This relies on Kubernetes β‰₯ 1.35 (ContainerRestartRules). The worker always exits 0, so a recycle reads as Completed, never CrashLoopBackOff.

RBAC for the OOM probe

The server detects out-of-memory kills by reading the kubelet's container status from the Kubernetes API. Bind its ServiceAccount to a Role that can get pods in its own namespace β€” nothing more:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: shiitake-pod-reader }
rules:
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get"]

Example Helm chart

The repository ships a reference chart under tests/chart that deploys either topology (a topology value picks one) β€” server + a configurable worker count, the pod-reader RBAC, the dispatch Service and worker NetworkPolicy for the split shape, an optional OpenTelemetry Collector, and tunable resources.limits. It is built for the end-to-end suite, so treat it as a worked example rather than a production-hardened chart.

Topologies

Two supported shapes. The binaries, the wire protocol and the HTTP API are identical in both β€” what changes is where the containers sit and how the worker addresses the dispatcher.

Single PodTwo Pods
Layoutserver + N worker containers in one Poda server Pod, plus worker Pods of their own
Dispatchws://127.0.0.1:8090/dispatchws://<service>:8090/dispatch, through a cluster-internal Service
Capturea shared emptyDira ReadWriteMany volume both Pods mount
Worker recycleper-container restartPolicy (k8s β‰₯ 1.35)the ordinary Pod restartPolicy
Pod-scoped controlsone setting for server and workers alikeset independently for each

Why split them

A NetworkPolicy selects a Pod, not a container. While the server and the workers share one, every egress the server legitimately needs β€” the Kubernetes API to classify an OOM-killed worker, a collector to export telemetry to β€” is necessarily also granted to the worker containers, which are the ones running arbitrary commands. Per-container controls such as the service-account token mount can be kept narrow, but network reach cannot. Hardening the workers and giving the server what it needs pull in opposite directions, and the operator has to choose one.

Split apart, the workers get a policy of their own β€” no ingress, egress only to DNS and the dispatcher β€” while the server pod keeps its reach. Neither side gives anything up.

What changes

Get that last one wrong and nothing errors: the workers retry, the server stays 0/1 Ready, and the rollout simply never completes.

The manifests

Four objects, plus the pod-reader RBAC the server already needs. A working chart for both topologies lives in tests/chart.

# 1. the server β€” dispatch bound wide, and its own Pod
apiVersion: apps/v1
kind: Deployment
metadata: { name: shiitake, labels: { app: shiitake } }
spec:
  replicas: 1
  selector: { matchLabels: { app: shiitake } }
  template:
    metadata: { labels: { app: shiitake } }
    spec:
      serviceAccountName: shiitake          # bound to the pod-reader Role
      containers:
        - name: server
          image: ghcr.io/tenzailabs/shiitake-server
          ports:
            - { name: api,      containerPort: 8080 }
            - { name: dispatch, containerPort: 8090 }
          env:
            - { name: SHIITAKE_DISPATCH_HOST, value: "0.0.0.0" }   # not loopback any more
            - name: SHIITAKE_AUTH_TOKEN
              valueFrom: { secretKeyRef: { name: shiitake, key: token } }
            - name: SHIITAKE_DISPATCH_TOKEN
              valueFrom: { secretKeyRef: { name: shiitake, key: dispatch-token } }
            - { name: SHIITAKE_CAPTURE_ROOT, value: /capture }
            - name: POD_NAME
              valueFrom: { fieldRef: { fieldPath: metadata.name } }
            - name: POD_NAMESPACE
              valueFrom: { fieldRef: { fieldPath: metadata.namespace } }
          readinessProbe:
            httpGet: { path: /api/v1/ready, port: 8080 }
          livenessProbe:
            httpGet: { path: /api/v1/health, port: 8080 }
          volumeMounts:
            - { name: capture, mountPath: /capture }
      volumes:
        - name: capture
          persistentVolumeClaim: { claimName: shiitake-capture }   # ReadWriteMany
---
# 2. how the workers find it. publishNotReadyAddresses is required:
#    the server is not ready until workers register, and they register here.
apiVersion: v1
kind: Service
metadata: { name: shiitake-dispatch }
spec:
  publishNotReadyAddresses: true
  selector: { app: shiitake }
  ports:
    - { name: dispatch, port: 8090, targetPort: dispatch }
# 3. worker Pods: their own id, the dispatch Service, and no API-server reach
apiVersion: apps/v1
kind: Deployment
metadata: { name: shiitake-workers, labels: { app: shiitake-worker } }
spec:
  replicas: 8                               # pool size β€” one worker per Pod
  selector: { matchLabels: { app: shiitake-worker } }
  template:
    metadata: { labels: { app: shiitake-worker } }
    spec:
      automountServiceAccountToken: false    # workers never talk to the API server
      containers:
        - name: worker
          image: your-registry/your-worker:latest
          env:
            - name: SHIITAKE_WORKER_ID          # unique per replica
              valueFrom: { fieldRef: { fieldPath: metadata.name } }
            - { name: SHIITAKE_DISPATCH_URL,   value: "ws://shiitake-dispatch:8090/dispatch" }
            - name: SHIITAKE_DISPATCH_TOKEN
              valueFrom: { secretKeyRef: { name: shiitake, key: dispatch-token } }
            - { name: SHIITAKE_CONTAINER_NAME, value: worker }
            - { name: SHIITAKE_CAPTURE_ROOT,   value: /capture }
            - { name: SHIITAKE_RESET_PATHS,    value: "/tmp,/var/tmp,/dev/shm" }
            - name: POD_NAME
              valueFrom: { fieldRef: { fieldPath: metadata.name } }
            - name: POD_NAMESPACE
              valueFrom: { fieldRef: { fieldPath: metadata.namespace } }
          volumeMounts:
            - { name: capture, mountPath: /capture }   # same path as the server
      volumes:
        - name: capture
          persistentVolumeClaim: { claimName: shiitake-capture }
---
# 4. the policy that is now expressible: DNS and the dispatcher, nothing else
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: shiitake-worker }
spec:
  podSelector:
    matchLabels: { app: shiitake-worker }
  policyTypes: ["Ingress", "Egress"]
  ingress: []
  egress:
    - to:
        - namespaceSelector: {}
          podSelector:
            matchLabels: { k8s-app: kube-dns }
      ports:
        - { protocol: UDP, port: 53 }
        - { protocol: TCP, port: 53 }
    - to:
        - podSelector:
            matchLabels: { app: shiitake }
      ports:
        - { protocol: TCP, port: 8090 }

The dispatch Service is cluster-internal by design. Keep it that way β€” no NodePort, no LoadBalancer. The bearer token guards the endpoint, but the dispatcher is not a public API.

Failure model

Once the server and the workers are separate Pods, either can die without the other. What each side does about it:

What happensWhat follows
A worker dies mid-command The socket drops; the server reconciles the handle to worker_died β€” or oom_container if the kubelet reports the container was OOM-killed. The Deployment replaces the Pod.
The server dies while workers are idle Workers reconnect on a 1s retry, re-resolving SHIITAKE_DISPATCH_URL so they find the replacement Pod. Nothing ran, so their sandboxes are still clean and no container is recycled.
The server dies with commands running Each affected worker SIGKILLs its command and exits 0 for a fresh container. It cannot reconnect: no reset ran, so its sandbox holds the killed command's leftovers. Exiting restores the clean slate and fences the worker out of the pool until it does.
A partition β€” neither side sees a close The server pings every worker, idle and in-flight, and evicts one silent past its window. The worker independently gives up after SHIITAKE_LEASE_TIMEOUT (45s) of silence, reaching the same two outcomes above. Without this a command would outlive the server that ordered it.
The server restarts Handles do not survive: the registry is in memory, so GET /api/v1/exec/{handle} for anything issued before the restart returns 404. The replacement never re-issues old work.

Capture is purged at startup. Because handles die with the process, every capture directory on the volume is unreachable after a restart β€” nothing can name it, and the sweeper only walks the registry. The server therefore clears SHIITAKE_CAPTURE_ROOT once at boot, before serving. Copy anything you need to keep out of the volume; do not treat it as an archive.

Two consequences worth designing around. A command killed by a server restart is not retried for you β€” the caller sees a handle that 404s and decides what to do. And if that caller retries, it runs the command a second time; shiitake guarantees a request is dispatched once, not that your command is idempotent.

Building a custom worker image

The worker is application-agnostic and ships as a static binary so it can run inside your toolchain. Copy it out of the published worker image into your own base image and set it as the entrypoint:

FROM your/toolchain:latest

# Pin by immutable digest β€” GHCR tags (incl. release tags) are mutable,
# so a `COPY --from` by tag can be silently moved. Get the digest from
# the package page or `docker buildx imagetools inspect <image>:<tag>`.
COPY --from=ghcr.io/tenzailabs/shiitake-worker@sha256:<digest> \
     /usr/local/bin/shiitake-worker /usr/local/bin/shiitake-worker

ENTRYPOINT ["/usr/local/bin/shiitake-worker"]

A few things to get right in a custom image:

Both the server and the worker are built for *-unknown-linux-musl, so the worker binary is fully static and runs in any Linux base image, including scratch (though you'll still want bash and your toolchain).

Configuration

Both binaries take their configuration from flags with SHIITAKE_* environment fallbacks.

Server

VariableDefaultPurpose
SHIITAKE_AUTH_TOKEN(empty)Required. Bearer token guarding every /exec route. The server refuses to start if unset.
SHIITAKE_HOST0.0.0.0HTTP API listen address.
SHIITAKE_PORT8080HTTP API listen port.
SHIITAKE_DISPATCH_TOKEN(empty)Required. Bearer token workers present on the dispatch upgrade. A separate secret from SHIITAKE_AUTH_TOKEN β€” dispatch and the public API are different trust boundaries.
SHIITAKE_DISPATCH_HOST127.0.0.1Worker dispatch listen address. Loopback is all a single-Pod deployment needs; set 0.0.0.0 for workers in their own Pods.
SHIITAKE_DISPATCH_PORT8090Worker dispatch listen port.
SHIITAKE_DEFAULT_WORKDIR/Working directory when a request omits workdir.
SHIITAKE_MAX_BODY_BYTES268435456Maximum accepted request body size (256 MiB).
SHIITAKE_CAPTURE_ROOT/captureRoot for the stdout/stderr capture files.
SHIITAKE_MIN_READY_WORKERS1Registered workers (idle + in-flight) the pool needs before /ready reports ready.
OTEL_EXPORTER_OTLP_ENDPOINT(unset)OTLP endpoint. When set, the server exports traces + metrics; otherwise it logs to stdout only.
OTEL_EXPORTER_OTLP_PROTOCOLhttp/protobufOTLP transport: grpc, http/protobuf, or http/json (all plaintext).
POD_NAME / POD_NAMESPACE(downward API)The server's own Pod, used by the container-OOM probe for workers that don't report a Pod of their own.

Worker

VariableDefaultPurpose
SHIITAKE_WORKER_IDworker-unknownIdentifier advertised to the dispatcher. Must be unique across the pool β€” take it from the Pod name when each worker is its own Pod.
SHIITAKE_DISPATCH_URLws://127.0.0.1:8090/dispatchFull URL of the server's dispatch endpoint. The default is the same-Pod case; point it at a Service to run the workers in their own Pods.
SHIITAKE_DISPATCH_TOKEN(empty)Required. Bearer token presented on the dispatch upgrade. Must match the server's.
SHIITAKE_CAPTURE_ROOT/captureMust match the server's capture root (shared volume).
POD_NAME / POD_NAMESPACE(downward API)This worker's own Pod, reported to the server so its container-OOM probe queries the right one. Omit outside Kubernetes.
SHIITAKE_CONTAINER_NAME(the worker id)This worker's container name within its Pod, for the same probe.
SHIITAKE_LEASE_TIMEOUT45Seconds of silence from the server before the worker gives up on the session. Idle it reconnects; mid-command it kills the command and exits for a fresh container. 0 waits forever.
SHIITAKE_RESET_PATHS(empty)Comma-separated scratch directories to empty between commands. Empty means "clear nothing".
SHIITAKE_RESTART_AFTER0Exit (recycle the container) after this many commands. 0 = stay resident; N = a full container teardown every N commands.
SHIITAKE_PTY_SHELLbash -iDefault shell for an interactive PTY when the open frame carries no command (whitespace-split argv). Point it at a tmux invocation to make the default terminal a tmux session.
SHIITAKE_HOME_ROOT/homeRoot under which a named PTY session's home is created (<root>/<name>). See naming the session's user.

HTTP API

The public API is versioned under /api/v1. Every /exec route requires Authorization: Bearer <token>; only /health and /ready are unauthenticated. The worker dispatch endpoint (/dispatch) is a separate internal router on its own listener, with its own bearer token (SHIITAKE_DISPATCH_TOKEN) and is never publicly reachable.

POST/api/v1/exec

Spawn a command. Returns {handle, started_at} with 202 Accepted; 429 if the pool has no idle worker. Request body:

{
  "command": "python3 -c 'print(2 + 2)'",
  "workdir": "/tmp",
  "timeout": 300.0,
  "env": { "PATH": "/usr/bin:/bin" },
  "drop_to": { "uid": 1000, "gid": 1000, "supplementary_gids": [], "umask": 7 }
}

Only command is required. Notes:

GET/api/v1/exec/{handle}

Status of a handle: status (running / completed / timeout / oomkilled / error), exit_code, exit_signal, exit_cause, timestamps, and per-stream byte counters.

GET/api/v1/exec/{handle}/stdout
GET/api/v1/exec/{handle}/stderr

Read captured output. Serves the capture file with HTTP Range support (206 / 416); tail the last N bytes with Range: bytes=-N.

DELETE/api/v1/exec/{handle}

Terminate the command (SIGTERM β†’ SIGKILL the process group). Idempotent on already-terminal handles.

GET/api/v1/health

Liveness. Always 200 while the server is serving, plus a pool snapshot (workers_idle, workers_inflight). Unauthenticated. An empty pool is not a reason to restart the process, so this endpoint deliberately ignores the pool β€” use /ready to gate traffic.

GET/api/v1/ready

Readiness. 200 once at least SHIITAKE_MIN_READY_WORKERS workers are registered, 503 otherwise β€” so an orchestrator probe can gate on the status code alone and keep traffic off a pod whose workers have not connected (or have all died). Unauthenticated. The body carries the same verdict:

{
  "ready": true,
  "service": "shiitake",
  "workers_idle": 7,
  "workers_inflight": 1,
  "workers_required": 1
}

A worker counts as registered whether it is idle or running a command, so a fully-busy pool stays ready β€” gating on idle workers alone would pull a pod out of rotation exactly when it is doing the most work. Raise SHIITAKE_MIN_READY_WORKERS to report unready below some fraction of a large pool rather than only at zero.

Exit causes

A finished handle reports one exit_cause:

CauseMeaning
normalThe command exited on its own (see exit_code).
signalThe command was killed by a signal (see exit_signal).
oom_containerThe container was OOM-killed. Detected externally from the kubelet's container status, never self-reported.
timeoutThe command exceeded its timeout.
worker_diedThe worker connection dropped mid-command for a non-OOM reason.
cancelledThe command was killed via DELETE.

The dispatch protocol

The contract between the server and a worker. You do not need this to run shiitake β€” drop in the shipped worker binary and it speaks it for you. You need it to write your own worker, or to reason about what crosses the wire when the two are in different Pods.

Connecting

The worker dials SHIITAKE_DISPATCH_URL and presents its bearer token on the HTTP upgrade request, not as a frame:

GET /dispatch HTTP/1.1
Upgrade: websocket
Authorization: Bearer <SHIITAKE_DISPATCH_TOKEN>

A wrong or missing token fails the handshake with 401, so an unauthenticated peer never gets to send a frame. After the upgrade every message is a JSON text frame with a kind discriminator. The worker sends Hello first and the server dispatches only after it arrives.

Frames

FrameDirectionMeaning
helloworker β†’ serverFirst frame. Advertises the worker and where it runs.
executeserver β†’ workerRun this command. One at a time per worker.
cancelserver β†’ workerSIGKILL the in-flight command's process group.
resultworker β†’ serverHow the command ended, plus cgroup resource usage.
pty_openserver β†’ workerOpen an interactive PTY and spawn the shell on it. Binary frames on the socket are then stdin (in) / pty output (out).
pty_resizeserver β†’ workerWindow resize for the session's tty.
pty_closeserver β†’ workerEnd the session β€” SIGHUP the process group and close the pty.
pty_exitworker β†’ serverThe shell exited (or the pty could not be opened). Reported after the between-session reset.
// worker β†’ server, immediately after the upgrade.
// `location` is optional: omit it and the server assumes the worker is a
// container of its OWN Pod named after worker_id (the single-Pod case).
// In the two-Pod topology it is required for OOM detection to work.
{"kind": "hello",
 "worker_id": "shiitake-workers-6d4b8f9c7-x2k9p",
 "location": {"pod": "shiitake-workers-6d4b8f9c7-x2k9p",
              "namespace": "shiitake",
              "container": "worker"}}

// server β†’ worker. drop_to is optional; absent means run as the worker's uid.
{"kind": "execute",
 "request_id": "0f3c…",
 "command": "echo hi",
 "working_dir": "/tmp",
 "env": {"PATH": "/usr/bin:/bin"},
 "timeout_secs": 300.0,
 "drop_to": {"uid": 1000, "gid": 1000, "supplementary_gids": [], "umask": 7}}

// server β†’ worker
{"kind": "cancel", "request_id": "0f3c…"}

// worker β†’ server. Output is NOT here β€” it is already on the capture volume.
// `error` is set only when the worker could not run the command at all.
{"kind": "result",
 "request_id": "0f3c…",
 "exit_code": 0,
 "exit_signal": null,
 "timed_out": false,
 "cancelled": false,
 "usage": {"memory_peak_bytes": 1048576, "memory_limit_bytes": 536870912,
           "cpu_user_seconds": 0.01, "cpu_system_seconds": 0.00},
 "error": null}

Capture, not streaming

Command output never crosses this socket. The worker redirects the child's stdout/stderr file descriptors straight into files, and the server reads those back over HTTP. Both sides mount the same volume at the same path (SHIITAKE_CAPTURE_ROOT) and agree on this layout:

<capture root>/<request_id>/stdout
<capture root>/<request_id>/stderr

The worker creates both files at exec start, so a known handle with no file means the command produced nothing on that stream β€” not an error. The server stats them for the byte counters it reports and serves them with HTTP range support. This is why the split topology needs a ReadWriteMany volume: the reader and the writer are in different Pods.

Liveness

The server sends WebSocket pings to every registered worker β€” idle and in-flight β€” and evicts one that goes silent, reconciling its handles. A worker answers pings while waiting, while running a command, and across its between-command reset. In the other direction the worker gives up on a session after SHIITAKE_LEASE_TIMEOUT of silence; see the failure model for what it does next, which depends on whether a command was running.

Clients

Two first-party clients wrap the HTTP API so you don't hand-roll requests.

Python (httpx)

from shiitake.client import AsyncShiitakeClient

async with AsyncShiitakeClient("http://shiitake:8080", auth_token="...") as c:
    result = await c.run({"command": "echo hi", "env": {"PATH": "/bin"}})
    print(result.stdout)

The client also exposes lower-level spawn / status / read / kill calls and an AsyncHandle with wait() and slurp() helpers. A synchronous ShiitakeClient is available too.

Rust (reqwest)

use shiitake_rs::{Client, ExecRequest};

let client = Client::new("http://shiitake:8080", Some(token));
let handle = client.spawn(&ExecRequest { command: "echo hi".into(), ..Default::default() }).await?;
let status = client.status(&handle.handle).await?;

The request/response types are re-exported from shiitake-server-api, so callers depend on just the one client crate.

Observability

Telemetry lives entirely in the server β€” the worker stays lean (no OTel dependencies). When OTEL_EXPORTER_OTLP_ENDPOINT is set, the server exports over OTLP:

Without an endpoint set, the server logs to stdout only. The OTLP exporter is plaintext-only by design (no TLS feature, to keep the static musl link free of aws-lc-rs); terminate TLS at a sidecar collector if you need it.

Interactive PTY

Alongside the fire-and-forget /exec, a worker can host an interactive terminal β€” a persistent shell on a real TTY (job control, vim, resize, tab-completion) with a live, bidirectional byte stream. A PTY session pins one worker for its whole lifetime rather than returning it to the pool per command, so it costs the pool one worker until it ends.

Opening a session

GET /api/v1/pty is a WebSocket upgrade, bearer-gated exactly like /exec (SHIITAKE_AUTH_TOKEN). The first message is a JSON open control frame; after it, binary frames are the byte stream β€” client β†’ server is stdin, server β†’ client is pty output β€” and JSON frames stay control. The server closes the socket when the shell exits.

// client β†’ server, the FIRST message. `command` empty uses the worker's
// default shell (SHIITAKE_PTY_SHELL). `drop_to` is optional, as on /exec.
{"op": "open",
 "command": ["bash", "-i"],
 "working_dir": "/tmp",
 "env": {"PATH": "/usr/bin:/bin"},
 "cols": 120, "rows": 40,
 "drop_to": {"uid": 1000, "gid": 1000, "supplementary_gids": []}}

// client β†’ server, at any time β€” reflow the tty
{"op": "resize", "cols": 80, "rows": 24}

The worker injects TERM=xterm-256color and runs the shell in working_dir under the caller-supplied drop_to β€” the same isolation as a command, just persistent. Scrollback is the client's; nothing is captured to disk. The server↔worker dispatch protocol carries matching pty_open / pty_resize / pty_close / pty_exit frames for anyone writing their own worker.

The default shell

When the open frame carries no command, the worker spawns SHIITAKE_PTY_SHELL (whitespace-split argv), defaulting to bash -i. Pointing it at a transparent tmux makes every terminal a tmux session without any protocol change:

# on the worker
SHIITAKE_PTY_SHELL="tmux new-session -A -D -s main"

Naming the session's user

drop_to is numeric, but a human terminal reads better as a login name than a bare uid. So drop_to accepts an optional name (plus create_home): while still privileged, before the drop, the worker best-effort ensures a matching /etc/passwd entry (and a home seeded from /etc/skel) so whoami, id -un, and the shell prompt resolve the uid to that name. The home lives at <SHIITAKE_HOME_ROOT>/<name> (default root /home), and the worker sets the shell's HOME to it so the directory, the passwd home, and HOME agree.

Shiitake stays identity-agnostic: it reflects whatever drop_to asks for. Deciding the uid and the name β€” e.g. mapping an authenticated principal to a stable uid and a display name β€” is the caller's policy.

Pinning & capacity

A session takes one idle worker and holds it until the shell exits or the client disconnects; on the way out the worker resets before rejoining the pool, so the next caller never inherits a dirty sandbox. If no worker is idle the upgrade closes with a try-again code. /health reports workers_interactive alongside workers_idle / workers_inflight, so you can see the capacity terminals are holding.

Persistence: a dropped client currently ends the session. Reconnect-after-drop (a tmux daemon that outlives the client, plus a worker held across detaches) is a later addition; the SHIITAKE_PTY_SHELL tmux knob above is the first half of it.

Security considerations

Shiitake runs untrusted commands by design. Its isolation properties β€” and where they stop β€” are worth understanding before you expose it.

What Shiitake provides

What you must handle

Container isolation is not a security boundary against a kernel exploit. Shiitake relies on the container runtime (and the limits you set) for isolation. For genuinely hostile workloads, run the worker pods under a hardened runtime such as gVisor or Kata Containers, apply a restrictive seccomp/AppArmor profile, drop Linux capabilities, and consider NetworkPolicy to keep commands from reaching your cluster network.

Development & testing

cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace                # unit + in-process integration tests

# full k3d cluster end-to-end (docker, k3d, kubectl, helm, python3, uv)
bash tests/build.sh && bash tests/setup.sh && bash tests/run.sh

Local + CI e2e tooling (k3d, kubectl, python) is managed by mise β€” run mise install. The Rust toolchain is pinned in rust-toolchain.toml. See AGENTS.md for the architecture notes and gotchas that keep the static musl build and the worker's clean-slate guarantee intact.

License

Shiitake is licensed under the Apache License 2.0. Contributions are welcome β€” open an issue or a pull request; by contributing you agree your contributions are licensed under the same terms.