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 these
exceptions: the operator pool and tenant verbs (SetPools, PutTenant and
their siblings), the claim env (WithEnv, Env/SetEnv/PatchEnv) and
setting claim metadata or on_expire, and reading a claim's net_route remain Go-only, Template.new takes no
claim_ref, 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). Two behaviors differ as well:
Session.close() and Lsp.stop() swallow not_found where Go returns it, and
a failed session command puts its merged output in ExitError.stdout with
stderr empty, where Go's ExitError.Stderr holds it. Wire fidelity is
pinned by the shared protocol fixture corpus: Go round-trips it in CI, the
Rust guest parses its requests and round-trips its responses, and Python
round-trips its requests and decodes each response.
Claim a node:24.04 sandbox (it carries make; rt:24.04 does not), 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("node:24.04", size="medium", ttl_seconds=600) as sb:
print(f"claimed {sb.id} on {sb.owner}")
sb.push("/work", project_tar()) # needs no egress policy
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), the
operator_token (the claim lifecycle,
sandboxes, info and lookup), or a tenant token (resource-creating
verbs only; operator surfaces answer it 403). Every node of a cell 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. Unlike the
Go SDK, where a canceled context closes any call, only run/exec take a
timeout here: every other data-plane call blocks until the guest answers
or the connection drops. A caller with a wall clock of its own passes deadline to a claim
(below) so the redirect walk cannot outlive it.
For HTTPS (endpoints) with a private CA, pass a
standard ssl.SSLContext:
import os
import ssl
from cocoonsandbox import Client
client = Client(
"https://node-a.sandbox.example.com",
api_token=os.environ["SANDBOXD_TOKEN"],
ssl_context=ssl.create_default_context(cafile="edge-ca.pem"),
)
with client.new("rt:24.04") as sb:
assert sb.run(["true"]) == 0Omit ssl_context for system trust. The SDK configures that context for
HTTP/1.1; use a dedicated context if another caller requires a different ALPN.
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) # lists the peers, then probes the entry node and every peer concurrentlysb = 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", "medium-4g", "large", "xlarge", "2xlarge" |
"small" |
resource tier: 1cpu/512M, 2cpu/1G, 2cpu/4G, 4cpu/4G, 4cpu/8G, 8cpu/16G |
volumes |
bare names or {name, mount?, mode?, attach_only?} 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 |
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 (it follows a sandbox that moved to
another node with a lookup, also when that node is gone; releasing one already
gone is not an error — double-release and reap races stay silent). Every other
call follows a move too: a 404 or a refused connection from the owner runs
client.lookup once, and the call is sent again to a different owner, since
every argument (stdin, file data, tar streams) is bytes. find_iter also retries
once before its first frame; it never replays matches already yielded. An exception from the
caller's own on_stdout/on_stderr is raised as it is and never retried, and
a released sandbox still raises a 404 APIError.
Volume sandboxes cannot hibernate, fork, checkpoint, or promote. Egress-lane
sandboxes cannot either, and a sandbox whose tun is turned off cannot promote;
each answers 409. Checkpoint
branches do not accept volumes of either mode in this version.
{"attach_only": True} on a volume entry leaves the mount to you: that
entry in sb.volumes carries no mount key; see
attach-only volumes for finding the device
and the consistency contract.
For a root client managing a tenant claim, see the
external volume controller example. It keeps the
controller reservation and access gate across detach, hibernate, and wake.
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 [...].
Entry semantics and placement: GET /v1/volumes, cluster.
Use sb.get_volumes(), sb.set_volumes([...]), and
sb.patch_volumes(add=[...], remove=[...]) on the same running sandbox. These
methods use the client's API token on the owning node; a sandbox token cannot
manage volumes. set_volumes([]) detaches all entries; None is rejected.
PATCH retains omitted names. Parameters take lists of names or Volume mappings.
The SDK sends volume policy to sandboxd and preserves attach_only directly.
from __future__ import annotations
from cocoonsandbox import Volume
volumes: list[str | Volume] = [
{"name": "dataset", "mount": "/datasets"},
{"name": "work", "mode": "rw", "attach_only": True},
]
view = sb.set_volumes(volumes)The caller mounts, flushes, and unmounts attach-only devices. See the
live volume contract for
writer exclusion and dirty-marker limits.
Each method takes timeout= (default: the client timeout, 120 s); a large
replacement can need longer, and a call that times out raises APIError with
status 0 and cancels the server run, so retry the same target.
Responses include volumes_want, volumes, states, and converging.
VolumePhase and VOLUME_ATTACHED, VOLUME_PENDING, and VOLUME_DETACHING
name the three states. A pending state with converging=False needs an explicit
retry. VolumeError, exported from cocoonsandbox and cocoonsandbox.errors,
is a subclass of APIError and retains a complete failure view in .response. Complete replies update both
public sets (sb.volumes_want, sb.volumes); incomplete errors preserve the last view. Serialize calls on one
handle. See the replacement tutorial and
API recovery contract.
sb.renew(3600) # the lease now ends an hour from nowrenew resets the lease to ttl_seconds from now and returns the granted
deadline, which it also stores in sb.deadline. 0 asks for the node default
of 5m, and the node caps the TTL at 24h. The grant is authoritative, and a
renew can shorten a lease as well as extend it. renew authenticates with the
sandbox's own token, so a handle from client.lookup can renew. An archived
sandbox is refused with an APIError of status 409: any call that reaches the
guest wakes it, and then it can renew.
sb.hibernate() # snapshot + stop atomically; memory freed
sb.exec("cat", "/tmp/state") # the next call that reaches the guest wakes it transparentlyhibernate 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). On a node at
max_running that call raises a 429 APIError,
which the SDK does not retry, and the sandbox stays hibernated. The TTL keeps
running — a hibernated sandbox is still reaped at its deadline, so claim
with a ttl_seconds that covers the idle period or renew before it ends.
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. When the sandbox's pool sets
reader, stat,
read_file, list_dir and pull on a hibernated or archived handle are served
without waking it, as of the guest sync just before the last hibernate, and the
handle's next call that is anything else wakes the sandbox on the same connection.
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.
The handle's sb.deadline remains the value returned when it was created or last
renewed, so after an archive/wake transition read the current one from
client.sandboxes() (wake).
children = sb.fork(2, ttl_seconds=600) # list[Sandbox], own leasesClones 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 lifecycleTemplates 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.
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-tripA 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 the peers that node sees to drop any healed replica, as
best-effort cleanup rather than a fleet-wide revocation; see
delete is eventual.
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-managersstart_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 URLdial_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 peersat_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 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 itstdin takes bytes, bytearray or memoryview; a non-empty str raises
TypeError.
timeout on exec and run is a wall clock over the dial and the run: at
its end the SDK cuts the connection and kills the reported pid, 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,
where only argv applies — the session owns the cwd, environment and user,
and stderr arrives merged into stdout. A command's environment is PATH,
TERM, the lane's proxy variables and the claim env, plus env, not the image's ENV. The
guest kills a foreground command whose connection dropped only once it next
writes output, so a run(timeout=) cut does not leave that to the drop: once
the guest has reported the command's pid, the cut sends that pid a kill over
a connection of its own (best effort, bounded to 5 s) before SandboxTimeout
is raised. A client that only drops the connection leaves a silent command
running in the guest; ps then kill stops it. spawn outlives the
connection by design. A command run through a session is the session's: it
reports no pid, so no cut kills it and it is not in ps; it runs to
completion, and the session's close() is what ends the 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 SIGKILLspawn 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). kill signals the process group the pid leads, so a
pipeline or a forked child goes with it. Killing an already-exited process is a no-op success.
An exited process leaves the table after 5 minutes; from then on its pid
raises SilkdError not_found.
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 idsIdle 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 bytespush is atomic against a truncated stream and needs no egress policy, so it
brings a project into a no-network sandbox whose pool allows no git host.
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 atomicPatterns are regular expressions evaluated in the guest — no shell quoting.
replace is atomic per file, not per list: it raises at the first missing,
unreadable or non-UTF-8 path and the files before it stay rewritten, so pass it
paths a find just returned. A find fails as a whole when one match's frame
would pass the 8 MiB cap.
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)
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)
sb.git_pull("/work/repo", auth=token)
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. Clone, push and pull reach the remote the way an exec does: directly on the egress lane, through the egress proxy elsewhere, so a pool whose policy does not allow the git host fails them with git's own error.
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() # one summary dict per live claim — never tokens
client.drain() # refuse new claims, run leases out
client.undrain()
client.cordon() # refuse new claims across restarts; moves in still land
client.uncordon()
client.attach(owner_addr, id, token) # bind a known handle, no lookup round-tripsandboxes() is scoped to the calling token, so a tenant sees only its own
claims; the fields are those of GET /v1/sandboxes.
drain() leaves live claims alone — poll info() until claimed is
zero. undrain() lifts only a drain and uncordon() only a
cordon; the
admission table compares the two.
SandboxError is the base; catch the narrowest type you handle:
APIError(verb, status, message)— control plane (HTTP status)SilkdError(kind, message)— typed guest failure;kindisbad_request(including a spawn the guest cannot start: missing binary, a cwd that is not a directory, no exec bit, auserthe guest cannot resolve) /not_found/unimplemented/internalExitError(code, stderr, stdout)— non-zero exit fromexecSandboxTimeout— a caller's timeout or deadline expired before the call finished; also aTimeoutErrorProtocolError— a failed relay dial or TLS handshake, or a broken stream (EOF, oversized or undecodable frame)
try:
sb.read_file("/work/missing.txt")
except SilkdError as e:
if e.kind == "not_found":
...