cocoon sandbox

Python SDK

from cocoonsandbox import Client

client = Client("10.0.0.5:7777", api_token="...")
with client.new("ghcr.io/cocoonstack/sandbox/rt:24.04") as sb:
    print(sb.exec("echo", "hello"))          # "hello\n"

pip install cocoonstack-sandbox — stdlib-only, no dependencies, and synchronous by design (agent frameworks that need async wrap calls in asyncio.to_thread, exactly like the OpenAI adapter does). It matches the Go SDK guest and data-plane surface, with two exceptions: operator pool retuning (SetPools/SetPoolsCluster) remains Go-only, and file and tar payloads are whole bytes values rather than streams (read_file/pull return them, write_file/push take them; the wire is chunked, the caller’s copy is not). Wire fidelity is pinned by the shared protocol fixture corpus that the Rust guest, Go, and Python all round-trip in CI.

A complete example

Claim a sandbox, push a project in, run a build, then freeze the built state and fan out two independent workers from that exact moment:

import io
import os
import tarfile

from cocoonsandbox import Client, ExitError


def project_tar() -> bytes:
    buf = io.BytesIO()
    with tarfile.open(fileobj=buf, mode="w") as tar:
        body = b"build:\n\techo built > out.txt\n"
        info = tarfile.TarInfo("Makefile")
        info.size = len(body)
        tar.addfile(info, io.BytesIO(body))
    return buf.getvalue()


def main() -> None:
    client = Client(os.environ["SANDBOXD_ADDR"], api_token=os.environ["SANDBOXD_TOKEN"])
    with client.new("rt:24.04", size="medium", ttl_seconds=600) as sb:
        print(f"claimed {sb.id} on {sb.owner}")

        sb.push("/work", project_tar())  # only ingestion path on the no-network lane
        try:
            print(sb.exec("sh", "-c", "cd /work && make build 2>&1"), end="")
        except ExitError as e:
            raise SystemExit(f"build failed (rc={e.code}): {e.stderr}")

        ckpt = sb.checkpoint("built")  # freeze the built state
        for i in range(2):
            with ckpt.new() as worker:  # each branch is fully independent
                print(worker.exec("sh", "-c", f"echo worker {i} && ls /work"), end="")


if __name__ == "__main__":
    main()

The rest of this guide is the per-method reference.

Connecting

client = Client("10.0.0.5:7777", api_token="...", timeout=120.0)

api_token is the node token — a root api_token (full access) or a tenant token (resource-creating verbs only; operator surfaces answer it 403). On a cluster every node shares the root token and the same tenants set. timeout bounds every control-plane request and the data-plane dial and upgrade; a guest stream then lives until the guest ends it, as in the Go SDK. A caller with a wall clock of its own passes deadline to a claim (below) so the redirect walk cannot outlive it.

Clusters need nothing extra: dial any node. On a warm miss the entry node answers with a redirect and new follows it transparently; the returned handle is bound to the owning node and all further calls go there directly. To recover a handle when only id + token survived (say, across a process restart):

sb = client.lookup(id, token)   # probes the entry node and every mesh peer concurrently

Claiming

sb = client.new("ghcr.io/cocoonstack/sandbox/rt:24.04",
                net="egress", size="medium", ttl_seconds=600,
                volumes=["imagenet", {"name": "weights", "mount": "/models"},
                         {"name": "scratch-db", "mode": "rw"}])
parameter values default meaning
net "none", "egress" "none" Cloud Hypervisor network shape: none disables the NIC and uses vsock-only I/O; egress attaches a bridge/CNI NIC
size "small", "medium", "large", "xlarge", "2xlarge" "small" resource tier: 1cpu/512M, 2cpu/1G, 4cpu/4G, 4cpu/8G, 8cpu/16G
volumes bare names or {name, mount?, mode?} mappings None attach and mount up to eight unique catalog dataset disks; an omitted mount defaults to /volumes/<name>; mode is "ro" (default) or "rw""rw" requires the catalog entry’s writable: true; accepted by Client.new and Template.new
mount bool True mount every requested volume. False attaches the devices and leaves the mounting to the workload; a mapping carrying mount is then a TypeError
ttl_seconds int server default 5m sandbox TTL, server-capped at 24h. The node reaps the sandbox after the TTL even if the client vanishes
deadline time.monotonic() value None keyword-only wall clock over the whole claim, redirect targets included: every request is bounded by whatever is left and a spent deadline raises TimeoutError. Without it each redirect candidate gets a fresh timeout, so a cluster with unreachable peers can cost a multiple of it. Also on Template.new and Checkpoint.new

new returns when the sandbox’s silkd answers: a warm hit is milliseconds, a cold key can take the full boot. A volume claim may consume an ordinary warm VM and returns only after every requested disk is mounted; sb.volumes contains dictionaries with the finalized name, effective mount, and (for rw entries) mode. Custom mounts must be absolute and clean, stay outside the guest OS tree, and cannot duplicate or nest. The handle exposes sb.id, sb.token, sb.owner, sb.deadline, and sb.from_checkpoint (the lineage edge when branched). sb.template_digest is the exact content identity when the claim cloned a promoted template; it is empty for other sources. Sandbox is a context manager; sb.close() releases it (releasing one already gone is not an error — double-release and reap races stay silent). Volume sandboxes cannot hibernate, fork, checkpoint, or promote. Checkpoint branches do not accept volumes of either mode in this version.

mount=False on client.new or template.new claims the same volumes without mounting them: the dictionaries in sb.volumes carry no mount key, and the workload finds each device by polling /sys/block/*/serial for the catalog name — not guaranteed present when the claim returns, typically within ~100ms — then confirms the /dev/<blk> node itself exists before mounting. Everything above describes the default (mount=True) and is unchanged by it. What changes is that the mount and its consistency are entirely yours: sandboxd writes and clears no dirty marker for an attach-only rw claim, because it cannot verify your unmount, so releasing without unmounting cleanly leaves the image as a crash would — see sandboxd-api for the full contract.

The caller-visible constraints are deliberate: volume claims may consume a warm VM, remain non-capturable, mount read-only by default, and require Cloud Hypervisor.

client.volumes() returns the fleet entries this token may use:

for volume in client.volumes():
    print(volume["name"], volume["default_mount"],
          volume["size_bytes"], volume["available"], volume["nodes"],
          volume.get("writable", False))

writable is present (and true) only for a writable entry; a read-only entry omits the key, so read it with .get, not [...].

Discovery returns the gossiped union and holder count; availability and size describe the connected node. Warm candidates retain normal ranking, filtered to nodes advertising every requested name. A promoted-template claim prefers a peer advertising both the template and every volume; when that intersection is empty, one volume holder may self-verify access to a shared template store before provisioning.

Hibernating

sb.hibernate()                        # snapshot + stop atomically; memory freed
sb.exec("cat", "/tmp/state")          # the next call that reaches the guest wakes it transparently

hibernate snapshots the VM and stops it in one atomic step. The handle stays valid: the first call that reaches the guest restores the VM (roughly a restore’s latency, tens of milliseconds on bare metal). The TTL keeps running — a hibernated sandbox is still reaped at its deadline, so claim with a ttl_seconds that covers the idle period. When to hibernate is your policy, unless the deployment opts into idle_hibernate_seconds (deploy), which hibernates idle claims automatically with the same transparent wake. A claim with a connection live when the sweep checks it (a relay stream, a buffered exec, a preview dial, an egress request) is not swept; the idle clock restarts when that connection ends.

Data-plane calls share a handle’s relay connection: after a call the SDK keeps the connection for 30 seconds (Client(..., keep_alive=...) tunes the window; 0 dials per call) and the next call on that handle sends its request on it, so a busy handle pays the dial, upgrade and TLS handshake once. A handle parks at most 8 idle connections. A kept connection counts as live for idle_hibernate_seconds until it closes, so keep the window below that setting; close and hibernate drop it at once. Streams (watch, open_pty, dial_port, an LSP session) take a connection of their own, and a guest whose silkd predates the back-to-back protocol gets one connection per call as before.

If that deployment also enables archive_after_seconds, archiving replaces the original claim deadline with the archive-retention deadline (or no deadline when archives are kept forever). Waking an archive starts a fresh server-default 5m lease. The existing handle’s sb.deadline remains the value returned when that handle was created; call client.sandboxes() to read the current server deadline after an archive/wake transition.

Forking

children = sb.fork(2, ttl_seconds=600)   # list[Sandbox], own leases

Clones the sandbox into fresh, fully independent claims: memory, disk, and guest state (sessions, processes, tmpfs) duplicate at the fork point, and each child gets a distinct machine identity. ttl_seconds bounds every child’s lifetime (0 = server default) — children never inherit the parent’s remaining lease. A running parent pauses briefly for the snapshot; a hibernated parent forks from its memory image without waking. All-or-nothing: on error no child survived. Count is capped at the node’s max_fork_count (default 16).

Promoting to a template

tpl = sb.promote("myproj:v1")     # publish current state
child = tpl.new()                 # clones the promoted state
assert tpl.content_digest and tpl.content_digest == child.template_digest
tpl.delete()                      # caller owns the lifecycle

Templates are keyed by (name, the sandbox’s network lane, its size); on the default local-disk backend they live on the owning node (a shared store makes every node resolve them); the returned Template handle is bound there. Its delete and volume-less new reach that node; new(volumes=...) may follow one volume-placement redirect. The name-based calls (client.new("myproj:v1"), client.delete_template(...)) route cluster-wide via template gossip and lag a promote/delete by about a gossip tick — prefer the handle right after promoting (see Templates on a cluster). tpl.content_digest identifies the published export bytes. A caller pinning the mutable name can compare a claim’s template_digest with its expected value and close/refuse a mismatch. Templates published by an older sandboxd have empty digests until they are re-promoted after the node is upgraded.

Checkpoints — branching and time travel

sb.write_file("/root/state.txt", b"v1")
ckpt = sb.checkpoint("after-setup")       # source keeps running
sb.write_file("/root/state.txt", b"v2")

branch = ckpt.new()                        # a fresh sandbox at the captured moment
branch.read_file("/root/state.txt")        # b"v1"
sb.read_file("/root/state.txt")            # b"v2" — source unaffected
ckpt.delete()
client.checkpoints()                       # node's checkpoints, newest first
known = client.checkpoint("ck_…")          # known id; no listing round-trip

A checkpoint captures memory, disk, and running processes without stopping the sandbox (the same brief pause a fork takes); ckpt.new(ttl_seconds=0) branches any number of independent sandboxes from that exact moment, and successive checkpoints of sources and branches form a tree. Checkpoints live in the node’s checkpoint store — a shared FUSE mount or a checkpoint_store of kind s3 lets any node branch them. client.checkpoints() lists the connected node’s records, while client.checkpoint(id) creates an entry-node-bound handle for an already known id without listing. new() follows an owner redirect and may heal a missing record locally; delete() acts on the handle’s bound node.

delete() also asks every peer that node currently sees to drop any replica a heal pulled — best-effort eventual cleanup, not a fleet-wide revocation. A peer that misses the broadcast (offline, partitioned, or joined later) keeps serving branches from its replica until the node’s checkpoint_ttl_hours ages it out; enabling peer heal requires that TTL to be set, so every healed replica has a cleanup bound while healing stays on. A node later run with healing off and that TTL back at 0 keeps such a replica until an explicit delete.

Language servers (LSP)

lsp = sb.start_lsp("python", "/work")   # flavor image provides the server
stream = lsp.request()                  # JSON-RPC byte stream (frame it yourself)
# ... speak Content-Length-framed JSON-RPC over stream.send()/recv() ...
stream.close()                          # ends the session and reaps the server
lsp.close()                             # same as lsp.stop(); both are context-managers

start_lsp spawns the language server the flavor image ships for the language (the python flavor bakes pylsp); the base image has none, so it raises the typed not_found. silkd is a broker — it pipes JSON-RPC bytes without parsing LSP semantics, so the caller frames (Content-Length) and correlates by request id. A server serves one request() stream for its lifetime: closing the stream reaps it (start a new one to keep working); lsp.stop() kills it early.

Reaching guest ports

conn = sb.dial_port(8080)                        # byte stream to 127.0.0.1:8080 in the guest
listener = sb.proxy_port("127.0.0.1:0", 8080)    # local listener piping to it
url = sb.preview_url(8080, ttl_seconds=1800)     # signed, shareable browser URL

dial_port returns a PortConn (send/recv/close_write/close, context-manager) relayed over the silkd protocol — it works on the no-network lane, where the vsock relay is the only way in. A dead port raises silkd’s not_found. proxy_port serves the port on a local listener for unmodified local tools (browsers, curl); close the returned socket to stop, and a guest port that closes ends the local connection too. preview_url mints a signed URL served by the node’s preview listener, clamped to the claim’s remaining lease — the URL dies with the sandbox, and a node without preview_listen answers 501. Minting is resource-creating and takes the api token, like fork and checkpoint.

Node info

client.info()   # pools, claims, drain, at_capacity/reason, and mesh peers

at_capacity: true means refill is parked because the node cannot start another VM, not that it is still filling its warm target; at_capacity_reason carries the engine’s reason. Both keys are absent while refill is not capacity-blocked.

Running commands

out = sb.exec("python3", "script.py")            # stdout; ExitError on rc != 0

code = sb.run(["bash", "-c", "make test"],
              cwd="/work", env={"CI": "1"}, user="ubuntu",
              stdin=input_bytes,
              on_stdout=lambda b: sys.stdout.buffer.write(b),
              on_stderr=lambda b: sys.stderr.buffer.write(b),
              timeout=600)                          # seconds; TimeoutError past it

timeout on exec and run is a wall clock over the dial and the run: at its end the connection is cut, which makes silkd kill the command, and TimeoutError is raised.

exec returns stdout and raises ExitError on a non-zero exit — carrying code, stderr, and the stdout produced before it failed. run streams raw bytes through the callbacks (chunk boundaries may split multi-byte sequences) and returns the exit code. user de-escalates inside the guest; session= routes the command into a persistent session.

Background processes

pid = sb.spawn("sh", "-c", "make build")     # returns immediately
sb.ps()                                       # [{pid, argv, detached, state, exit_code?, ...}]
code = sb.logs(pid, on_stdout=out.append)     # replay the bounded ring; None while running
code = sb.attach(pid, on_stdout=out.append)   # replay, then follow live until exit
sb.kill(pid)                                  # default SIGKILL

spawn starts the command detached with a bounded output ring; logs replays it, attach follows live output until exit (replay and live stream hand off atomically). Killing an already-exited process is a no-op success.

Sessions

A session is a real persistent shell: cwd, env and shell state survive across calls.

sess = sb.session(cwd="/work", env={"PATH": "..."})   # context-manager
sess.exec("export", "MARK=1")            # persists
sess.exec("sh", "-c", "echo $MARK")      # "1\n"
sess.close()

sb.sessions()                            # live session ids

Idle sessions are reaped guest-side after 30 minutes.

Files

sb.write_file("/work/a.txt", b"data")     # atomic; mode=0o755 optional
data = sb.read_file("/work/a.txt")
ents = sb.list_dir("/work")               # [{"name", "kind", "size"}]
info = sb.stat("/work/a.txt")             # {"kind", "size", "mode", "mtime_epoch_secs"}
sb.mkdir("/work/sub", parents=True)
sb.remove("/work/sub", recursive=True)
sb.rename("/a", "/b")

Writes stream any size and commit via temp-file rename: a mid-stream failure never leaves a truncated destination, and overwriting an executable keeps its exec bit.

Project trees

sb.push("/work", tar_bytes)    # extract a tar stream under /work (atomic)
tar = sb.pull("/work")         # /work back as tar bytes

push is atomic against a truncated stream and the only project-ingestion path on the no-network lane.

matches = sb.find("/work", r"TODO|FIXME", glob="*.py")
from contextlib import closing

with closing(sb.find_iter("/work", r"TODO")) as stream:   # streamed; closing ends the walk in the guest
    for m in stream:
        if m["line"] > 100:
            break
# [{"type", "file", "line", "content"}]; glob is anchored *? wildcards on the file name

results = sb.replace(["/work/main.py"], r"foo", "bar")
# [{"type", "file", "replacements"}]; per-file atomic

Patterns are regular expressions evaluated in the guest — no shell quoting.

Watching

w = sb.watch("/work", recursive=True)
for ev in w:                       # {"type", "kind", "path"}
    print(ev["kind"], ev["path"])  # created|modified|deleted|renamed
w.close()

watch returns once the guest acknowledges the watch is armed — events caused after it returns are guaranteed captured. A bad path fails synchronously; if the consumer falls too far behind, iteration raises the terminal overflow instead of silently dropping events. Iteration also ends when the relay drops, which w.error tells apart from a clean close (None).

Git

sb.git_clone(url, "/work/repo", branch="main", depth=1, auth=token)  # egress lane only
st = sb.git_status("/work/repo")          # {"type", "branch", "ahead", "behind", "files", "truncated"?}
sb.git_add("/work/repo", ["a.txt"])
sha = sb.git_commit("/work/repo", "message", "Dev <dev@example.com>")
sb.git_push("/work/repo", auth=token)     # egress lane only
sb.git_pull("/work/repo", auth=token)     # egress lane only
br = sb.git_branches("/work/repo")        # {"type", "current", "branches"}
sb.git_create_branch("/work/repo", "feature")
sb.git_checkout("/work/repo", "feature")
sb.git_delete_branch("/work/repo", "feature")

Results are structured (porcelain v2 under the hood), never scraped stdout. Auth tokens travel as an in-memory header, never touching guest disk. On the no-network lane, clone/push/pull raise a typed unimplemented error pointing at push.

Terminals

pty = sb.open_pty(cols=120, rows=40)      # context-manager; pty.pid is the guest process
pty.write(b"make test\n")
data = pty.read()                         # b"" when the shell exits
pty.exit_code                             # the shell's status, once read() returned b""
pty.resize(200, 50)
pty.close()

Node operations

sb = client.new("rt:24.04", claim_ref="ns/workload")
client.sandboxes()                     # id, key, deadline, claim_ref — never tokens
client.drain()                         # cordon: refuse new claims, run leases out
client.uncordon()
client.attach(owner_addr, id, token)   # bind a known handle, no lookup round-trip

sandboxes() is scoped to the calling token, so a tenant sees only its own claims. drain() leaves live claims alone — poll info() until claimed is zero.

Errors

SandboxError is the base; catch the narrowest type you handle:

try:
    sb.git_clone(url, "/work/repo")
except SilkdError as e:
    if e.kind == "unimplemented":   # no-network lane: fall back to push
        sb.push("/work/repo", tar_bytes)