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.
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.
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
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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()
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.
SandboxError is the base; catch the narrowest type you handle:
APIError(verb, status, message) — control plane (HTTP status)SilkdError(kind, message) — typed guest failure; kind is
bad_request (including a spawn the guest cannot start: missing binary, a
cwd that is not a directory, no exec bit) / not_found / unimplemented /
internalExitError(code, stderr, stdout) — non-zero exit from execProtocolError — a failed relay dial or TLS handshake, or a broken stream
(EOF, oversized or undecodable frame)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)