Every HTTP call to a sandboxd agent, and the one error vocabulary they
produce.
Client confinement, exactly as CrowdControl.Backend.Docker.API and
CrowdControl.Backend.Kubernetes.API do it: no other module builds a Req
request to an agent, and no other module invents an error shape.
CrowdControl.Session only ever sees {:error, {:sandboxd, _}}.
Vocabulary
{:sandboxd, :unauthorized}—401. On reattach this most likely means:sandboxd_secretwas rotated, so the derived token no longer matches the one the sandbox was started with. That fails closed, deliberately.{:sandboxd, :not_found}—404.{:sandboxd, :already_executed}—409fromPOST /v1/exec. One exec per sandbox lifetime.{:sandboxd, :not_started}—409fromPOST /v1/stdinbefore any exec.{:sandboxd, {:conflict, error}}— any other409.{:sandboxd, {:http_status, status, message}}— anything else non-2xx.{:sandboxd, {:transport, reason}}— the connection failed.{:sandboxd, {:unexpected_body, body}}— a 2xx whose shape is wrong.{:sandboxd, :ready_timeout}— fromawait_health/2only.
Authentication
authorization: Bearer <endpoint.token>, then endpoint.headers merged
over it. That order matters: a provider whose transport claims
authorization for its own authentication (the Kubernetes API server's pod
proxy) sets it in headers and moves the agent token to
x-cc-authorization, which the agent accepts identically.
Test seam
Transport configuration, including :adapter, rides in
endpoint.req_options and is merged into the Req call. A hermetic test
builds an endpoint with req_options: [adapter: fn req -> ... end] and needs
no daemon, no container, and no socket. CrowdControl.ReqAdapter is what
makes a function legal there under Req 0.7, which deprecated it.
Retries are off. Req's default retry: :safe_transient turns one refused
connection into 1s + 2s + 4s of backoff, and every caller here treats
{:error, _} as authoritative — the reaper's fail-open path most of all.
Summary
Functions
Poll GET /v1/health until it answers, or timeout elapses.
POST /v1/exec. Env travels in the JSON body, never in argv or a query
string, and is never logged.
GET /v1/health. The only unauthenticated route, and it returns no state.
PUT /v1/files/*path. Writes body inside the sandbox at mode.
Reject a path with ./.. segments or a null byte, and require it absolute.
POST /v1/shutdown. Kills the CLI and halts the agent.
GET /v1/status, optionally long-polled for up to wait_ms server-side.
GET /v1/stream?offset=N as an into: :self response.
Fold a mid-stream failure into this module's error vocabulary.
POST /v1/stdin, base64-encoded so arbitrary bytes survive JSON.
Types
@type status() :: %{ alive: boolean(), exit_status: integer() | nil, bytes: non_neg_integer(), started: boolean() }
Agent status, as reported by GET /v1/status.
Functions
@spec await_health(CrowdControl.Provider.Endpoint.t(), timeout()) :: :ok | {:error, term()}
Poll GET /v1/health until it answers, or timeout elapses.
This is what makes CrowdControl.Provider.acquire/1's contract satisfiable:
provisioning that reports success before the agent answers is the single
largest source of flaky remote backends.
@spec exec(CrowdControl.Provider.Endpoint.t(), String.t(), [String.t()], %{ optional(String.t()) => String.t() }) :: :ok | {:error, term()}
POST /v1/exec. Env travels in the JSON body, never in argv or a query
string, and is never logged.
@spec health( CrowdControl.Provider.Endpoint.t(), keyword() ) :: :ok | {:error, term()}
GET /v1/health. The only unauthenticated route, and it returns no state.
@spec put_file( CrowdControl.Provider.Endpoint.t(), Path.t(), iodata(), non_neg_integer() ) :: :ok | {:error, term()}
PUT /v1/files/*path. Writes body inside the sandbox at mode.
Traversal is rejected here as well as by the agent. Two checks for one
property is not redundancy: the client-side one is the only reason a caller
gets a comprehensible error instead of a 400, and the server-side one is
the only one that holds against a caller that is not this library.
Reject a path with ./.. segments or a null byte, and require it absolute.
Public and pure so the check is testable without an agent.
@spec shutdown(CrowdControl.Provider.Endpoint.t()) :: :ok
POST /v1/shutdown. Kills the CLI and halts the agent.
Destroying the sandbox is the provider's job. A 404 or a transport error
is success here: an agent that cannot be reached is already not running.
@spec status(CrowdControl.Provider.Endpoint.t(), non_neg_integer()) :: {:ok, status()} | {:error, term()}
GET /v1/status, optionally long-polled for up to wait_ms server-side.
A long poll is what stops a session from spinning on a live sandbox that has simply not produced output yet.
@spec stream(CrowdControl.Provider.Endpoint.t(), non_neg_integer()) :: {:ok, Req.Response.t()} | {:error, term()}
GET /v1/stream?offset=N as an into: :self response.
Returns the raw Req.Response because the caller must keep it to call
Req.parse_message/2 and Req.cancel_async_response/1 — the same
cancel-and-resume backpressure loop CrowdControl.Backend.Docker uses.
Offsets are 0-indexed: the agent serves a byte offset directly, with none of
tail -c +N's 1-indexed hazard.
:receive_timeout is the silent-sandbox watchdog
It is the only thing that turns "container is up, agent answers nothing,
ever" into an :eof — nothing else in the stack notices, because the
connection stays open and no bytes are owed. When it fires, Req delivers a
normal {:error, %Finch.TransportError{reason: :timeout}} message rather than
raising, so the reader's existing error path handles it.
It is set to 40s, deliberately longer than
the agent's own idle stream window: the agent should end an idle response
first, so an ordinary interactive pause produces a clean :done and a
re-request rather than looking like a dead sandbox.
Fold a mid-stream failure into this module's error vocabulary.
Req.parse_message/2 hands back a raw %Finch.TransportError{} for a
connection that died under an open stream, and that is the one path where a
failure reaches the reader without passing through normalize/1. Without
this, the module's promise that callers only ever see {:sandboxd, _} would
hold everywhere except the failure mode most likely to end up in a log.
@spec write(CrowdControl.Provider.Endpoint.t(), iodata()) :: :ok | {:error, term()}
POST /v1/stdin, base64-encoded so arbitrary bytes survive JSON.