Drives a CLI through the sandboxd agent over HTTP, on any substrate.
Requires the optional :req dependency and a CrowdControl.Provider:
CrowdControl.run("hello",
backend:
{CrowdControl.Backend.Sandboxd,
provider: {CrowdControl.Provider.Docker, image: "crowd_control/sandbox:dev", egress: :allow}}
)Why this exists next to CrowdControl.Backend.Docker
Backend.Docker is a transport bolted to a substrate: it knows both how
to create a container and how to move bytes through a FIFO and a tee file.
Backend.Kubernetes had to reimplement the second half for pods. A VM has no
exec API at all, so a third substrate meant a third transport.
This backend splits those apart. Bytes always move the same way — one HTTP
protocol to one agent — and CrowdControl.Provider owns the substrate. A new
substrate is provisioning code and nothing else.
Backend.Docker is not deprecated and is not going anywhere. It works with
any image that has sh and tail; this one needs an image containing our
agent. Which trade you want is yours to make.
Callback mapping
| Callback | Implementation |
|---|---|
provision/1 | CrowdControl.Provider.acquire/1, which returns only once GET /v1/health answers |
exec/4 | CrowdControl.Agent.sandbox_files/1 via PUT /v1/files, then POST /v1/exec with env in the JSON body |
start_reader/3 | GET /v1/stream?offset=N with Req into: :self — plain bytes, no demux |
write/2 | POST /v1/stdin {data: base64} |
await_exit/2 | GET /v1/status?wait_ms=…, long-polled server-side |
alive?/1 | GET /v1/health |
destroy/1 | CrowdControl.Provider.release/1, idempotent |
list_live/1 | CrowdControl.Provider.list_live/1, so CrowdControl.Reaper needs no change |
reattach/2 | CrowdControl.Provider.reconnect/1, then start_reader/3 at the persisted offset |
scrub/1 | drop the endpoint, scrub the config, then CrowdControl.Provider.scrub/1 |
Agent configuration files land here before the CLI starts
A CLI that reads configuration from disk cannot be configured by argv or by
environment alone: CrowdControl.Agent.Omp resolves a custom provider's
baseUrl out of models.yml, and on a remote substrate a host temp
directory is not visible to it. exec/4 asks the resolved
CrowdControl.Agent adapter for its CrowdControl.Agent.sandbox_files/1
and PUTs each one before the POST /v1/exec, so the file is there when the
CLI opens it. See push_file/4.
Live and resume are the same code path
start_reader/3 at offset 0 is the resume path. The agent's capture file is
byte-for-byte the same artifact as Backend.Docker's tee file, so the
cursor stays %{byte_offset:, buffer:} and a line split across a crash
rejoins byte-exactly. Offsets are 0-indexed here; tail -c +N is 1-indexed,
and that + 1 is a documented hazard this transport simply does not have.
The stream ending is not end-of-output
/v1/stream ends either because the CLI is finished or because nothing new
arrived within the agent's idle window. Those cannot be distinguished in-band
without injecting a keepalive byte into a stream whose offsets are
load-bearing, so the reader asks GET /v1/status: still alive, or more bytes
than it has consumed, means re-request from the current offset. Otherwise it
is EOF.
Backpressure
Identical in shape to Backend.Docker's, because the underlying problem is
identical: Req into: :self gives no backpressure, chunks pile into the
reader's mailbox whether or not CrowdControl.Session keeps up, and Req
has no pause primitive. It has cancellation, and this read is resumable by
construction, so "pause" is cancel and "resume" is re-requesting from the
offset already delivered. No new mechanism, and nothing is lost or duplicated
because the offset is exact.
Nothing secret is persisted
The handle goes into CrowdControl.Store. The endpoint does not: it holds a
derived token, a base URL whose port is reassigned on every reconnect, and
possibly a live tunnel. scrub/1 drops it wholesale and the token is
re-derived from the persisted session_key on reattach. Rotating
:sandboxd_secret therefore fails reattach closed with
{:sandboxd, :unauthorized}, which is the intended trade against a live
credential at rest.
Summary
Functions
Age of the sandbox in milliseconds, delegated to the provider.
Write a file inside the sandbox.
Types
Functions
@spec age_ms(t()) :: non_neg_integer() | nil
Age of the sandbox in milliseconds, delegated to the provider.
CrowdControl.Reaper uses this for the grace period that keeps a
mid-provision sandbox from being reaped before its store record exists.
@spec push_file(t(), Path.t(), iodata(), non_neg_integer()) :: :ok | {:error, term()}
Write a file inside the sandbox.
Exists for CrowdControl.Agent.Omp's :agent_dir obligation: a rendered
provider config has to land inside the sandbox, and on a remote substrate a
host temp directory is not visible there. It is deliberately not
CrowdControl.Backend.push_workspace/2 — this ships one rendered file, not
a workspace, and general workspace transfer stays out of scope.
exec/4 calls this for each CrowdControl.Agent.sandbox_files/1 entry, so
an adapter does not have to reach for it. It stays public because a caller
driving this backend directly has no other way in.