RunR v0.5.7

Documentation

runr-sandbox

Python client for the RunR Sandbox API: start isolated sandboxes on your own Kubernetes cluster, run commands and code in them, move files in and out, and remove them again. Every sandbox is a pod under the strongest isolation the region offers (Kata Containers or gVisor), without network access unless you allow it.

pip install runr-sandbox

Python 3.9 or newer. Dependencies: httpx and, for the exec WebSocket, websockets.

Configuration#

Argument Environment variable Meaning
api_url RUNR_API_URL your RunR address, e.g. https://runr.example.com (/api is appended)
api_key RUNR_API_KEY an API token (Settings → API Tokens)
tenant_id RUNR_TENANT_ID the organization, on multi-tenant installations (sent as X-Tenant-ID)
timeout HTTP timeout in seconds (default 300)
export RUNR_API_URL=https://runr.example.com
export RUNR_API_KEY=...

Quick start#

from runr_sandbox import Sandbox

with Sandbox.create(runtime="python", ttl_seconds=600) as sandbox:
    result = sandbox.exec("python3 --version")
    print(result.exit_code, result.stdout)
# leaving the block removes the sandbox

Sandbox.create() accepts:

Option Example Meaning
runtime, version "python", "3.14" a runtime of the RunR catalog (default python, its default version)
image "docker.io/library/alpine:3.21" any image instead of a runtime (it needs /bin/sh; tar for directory transfer)
cpu, memory, disk "500m", 512, "1Gi" resources; memory and disk in MiB or as a quantity
ttl_seconds, idle_timeout 900, 300 lifetime and idle timeout in seconds
network "none", ["pypi.org:443", "10.0.0.0/8"] no traffic (default) or an egress allowlist of hosts / CIDRs
env {"API_KEY": "..."} environment of every command
files {"/workspace/main.py": "print(1)"} files written before the sandbox is returned (str or bytes)
region, sandbox_class "eu-1", "gvisor" where and under which isolation (auto, kata, kata-fc, gvisor, …)
read_only_root, pids_limit True, 256 hardening options
metadata {"agent": "planner"} your labels, filterable in Sandbox.list(metadata=...)
wait, start_timeout True, 180 wait until the sandbox runs (a sandbox that does not start in time is removed)

Attach to an existing sandbox with Sandbox.connect(sandbox_id) and list them with Sandbox.list(status="active", metadata={"agent": "planner"}).

Commands#

result = sandbox.exec("ls -la /workspace")            # shell string
result = sandbox.exec(["python3", "-c", "print(42)"])  # argv list
result = sandbox.exec("cat > in.txt", stdin="data", timeout=30, env={"A": "1"}, cwd="/workspace")

result.ok          # exit_code == 0 and not timed_out
result.exit_code   # 137 when the timeout killed it
result.stdout      # text; result.stdout_bytes for the raw bytes

Streaming#

import sys

for event in sandbox.exec_stream("pip install --user requests"):
    if event.type == "stdout":
        print(event.text, end="")
    elif event.type == "stderr":
        print(event.text, end="", file=sys.stderr)
    elif event.type == "exit":
        print("exit code", event.exit_code)

Output arrives as Server-Sent Events while the command runs; event.data holds the bytes.

Interactive commands: the exec WebSocket#

exec_ws() runs a command over the exec WebSocket. The session sends input while the command runs, so an interactive program reads each line as it is sent:

with sandbox.exec_ws(["python3", "-i", "-q"]) as session:
    session.send_stdin("x = 6 * 7\n")
    session.send_stdin("print(x)\n")
    session.close_stdin()                 # end of input: the interpreter exits
    result = session.wait()               # exit code and the collected output
    print(result.exit_code, result.stdout)

Iterate over the session to read the output as it arrives (stdout / stderr events, then one exit event); send_stdin() may be called from another thread meanwhile. kill() stops the command (its exit event has killed set, exit code 137); leaving the block closes the connection, which stops a command that still runs.

With tty=True the command runs on a terminal of cols × rows (default 80 × 24): programs see a TTY (prompts, colours, line editing), stdout and stderr arrive together as stdout events with terminal line endings, resize() changes the size, and close_stdin() sends Ctrl+D:

import shutil

cols, rows = shutil.get_terminal_size()
with sandbox.exec_ws("bash", tty=True, cols=cols, rows=rows) as session:
    session.send_stdin("ls --color=auto /workspace\n")
    session.resize(120, 40)
    session.send_stdin("exit\n")
    for event in session:
        if event.type == "stdout":
            print(event.text, end="")

exec_ws() takes the options of exec() (stdin, timeout, env, cwd), or code with language instead of a command. The WebSocket opens on the host of RUNR_API_URL (wss:// for https) with a one-time token, which the SDK never logs; ssl_context sets its TLS settings (default: the CA bundle of SSL_CERT_FILE / SSL_CERT_DIR, else certifi's). AsyncSandbox has the same as async with sandbox.exec_ws(...) as session:, with await session.send_stdin(...) and async for event in session.

Running code#

result = sandbox.run_code("""
import matplotlib.pyplot as plt
plt.plot([1, 2, 3], [1, 4, 9])
plt.savefig("/output/plot.png")
print("done")
""", language="python")

print(result.stdout)              # "done\n"
print(result.files_changed)       # [FileChange(path='/output/plot.png', size=...)]
for output in result.outputs:     # images saved under /output
    output.save("plot.png")

language is python, javascript, typescript, bash, sh, php, ruby, go or the id of a runtime that runs code through an exec command; by default the sandbox's own runtime. run_code_stream() streams the output and ends with a result event.

Files#

sandbox.files.write("/workspace/data.csv", "a,b\n1,2\n")
sandbox.files.write("/workspace/run.sh", "#!/bin/sh\necho hi\n", mode=0o755)
data = sandbox.files.read("/workspace/data.csv")           # bytes
text = sandbox.files.read_text("/workspace/data.csv")
for entry in sandbox.files.list("/workspace"):
    print(entry.type, entry.size, entry.path)
sandbox.files.mkdir("/workspace/src")
sandbox.files.remove("/workspace/src", recursive=True)

sandbox.files.upload_dir("./project", "/workspace/project")      # as one tar archive
sandbox.files.download_dir("/workspace/project", "./result")
sandbox.files.upload_files({"a.txt": "A", "sub/b.bin": b"\x00"}, "/workspace")

Writable places are /workspace (the working directory), /output, /tmp and /home/sandbox.

Background processes#

server = sandbox.processes.start("python3 -m http.server 8000", cwd="/workspace")
print(sandbox.processes.list())
print(sandbox.processes.logs(server.id).stdout)
for event in sandbox.processes.follow(server.id):   # until the process ends
    print(event.text, end="")
sandbox.processes.kill(server.id, signal="TERM")

Lifetime#

sandbox.set_timeout(1800)      # live 30 more minutes (capped by the region's maximum)
sandbox.keepalive()            # reset the idle timer
sandbox.pause(); sandbox.resume()
sandbox.set_network(["api.example.com:443"])
sandbox.kill()                 # remove now; close() also releases the HTTP connections

asyncio#

AsyncSandbox has the same methods as coroutines and async iterators:

import asyncio
from runr_sandbox import AsyncSandbox

async def main():
    async with AsyncSandbox.create(runtime="nodejs") as sandbox:
        async for event in sandbox.exec_stream(["node", "-e", "console.log(1 + 1)"]):
            print(event.type, event.text)

asyncio.run(main())

Example: an AI agent code interpreter#

A tool an LLM agent can call: it runs the model's code in a sandbox that lives for the whole conversation and returns what the code printed plus any charts it drew.

import base64
from runr_sandbox import Sandbox

class CodeInterpreter:
    """One sandbox per conversation; the agent calls run() as a tool."""

    tool_schema = {
        "name": "run_python",
        "description": "Run Python code in an isolated sandbox. Save charts to /output/*.png.",
        "input_schema": {
            "type": "object",
            "properties": {"code": {"type": "string", "description": "Python source code"}},
            "required": ["code"],
        },
    }

    def __init__(self):
        self.sandbox = Sandbox.create(
            runtime="python",
            ttl_seconds=3600,
            idle_timeout=900,
            network=["pypi.org:443", "files.pythonhosted.org:443"],  # pip only
            metadata={"purpose": "code-interpreter"},
        )

    def run(self, code: str) -> dict:
        result = self.sandbox.run_code(code, language="python", timeout=120)
        return {
            "exit_code": result.exit_code,
            "timed_out": result.timed_out,
            "stdout": result.stdout[-20000:],
            "stderr": result.stderr[-20000:],
            "files_changed": [f.path for f in result.files_changed],
            "images": [
                {"path": o.path, "media_type": o.mime, "data": base64.b64encode(o.data).decode()}
                for o in result.outputs
            ],
        }

    def close(self):
        self.sandbox.close()


interpreter = CodeInterpreter()
try:
    # In the agent loop: when the model calls the tool, pass its input on and return the result
    observation = interpreter.run("import math\nprint(math.factorial(20))")
    print(observation["stdout"])
finally:
    interpreter.close()

The code runs under the region's sandbox isolation, as a non-root user with no service-account token, inside the CPU, memory, disk and process limits you set; with the allowlist above it can reach the package index and nothing else.

Errors#

All exceptions derive from RunrSandboxError (status, message, reason, body):

Exception When
AuthError 401 / 403 — no token, an invalid one, or a token without the ability
NotFoundError 404 — unknown sandbox, process or file
ConflictError 409 — the sandbox is not running (ended or paused)
QuotaError 422 with a quota_* reason — e.g. quota_concurrent, quota_cpu, quota_seconds
ValidationError other 422 answers — invalid options, unavailable region, runtime or sandbox class
PayloadTooLargeError 413 — a file above the upload / download limit
RateLimitError 429 — retry_after holds the seconds to wait
SandboxStartTimeout the sandbox did not run within start_timeout (also a TimeoutError)

On the exec WebSocket, RunrSandboxError carries the reason exec_error when the API ends the command with an error message, ws_closed when the connection ends before the exit event, and ws_error, ws_handshake or ws_timeout when it cannot be opened; a refused upgrade maps its HTTP status to the classes above.

Development#

pip install -e '.[test]'
pytest

The tests run against an in-process fake of the API (tests/fake_server.py), which serves the exec WebSocket with its own RFC 6455 framing.

License#

MIT

RunR v0.5.7 · MIT License