The sandbox backend contract: one behaviour, one facade, one error taxonomy.
A sandbox is a machine an agent runs in, owned by a provider (Sprites,
E2B and Daytona ship here; a host can register more). This module is both
the @behaviour an adapter implements and the facade a host application
calls — call sites never name an adapter module, they dispatch through
here on either a provider atom (creation-side operations) or the
provider tag carried by a Managoat.Sandbox.Handle /
Managoat.Sandbox.Command.
The semantics below are normative for every adapter;
Managoat.Sandbox.ConformanceCase pins them, and Managoat.Sandbox.Fake
is the reference implementation.
Lifecycle semantics
create/2is name-keyed and idempotent-adopting: creating a name that already exists returns{:ok, handle}for the existing sandbox.get/1must return{:error, :not_found}for a definitively absent sandbox and a different error for anything transient. Callers use the distinction to decide whether a parked disk (holding agent memory) may be given up — misclassifying a network blip as not-found loses data.destroy/1tolerates an already-gone sandbox (:ok).list_all_names/0returns the full account view or refuses with{:error, :truncated}— never a partial set that looks whole.suspend/1/resume/1park and wake a sandbox. Adapters whose platform parks implicitly (scale-to-zero) implement them as no-ops but still advertise:suspend— the flag answers "does idle parking preserve the disk cheaply?", and the idle sweep destroys instead where it is absent. A failed suspend call degrades to destroy (an unparked sandbox keeps billing); a failed resume leaves the row suspended (the disk is the agent's memory).
Exec semantics
exec/4blocks until the command exits and never raises: a nonzero exit is{:ok, output, code}(the script failed — readable), an unreachable sandbox is{:error, reason}(retriable by the caller).spawn/4starts a streaming command. The adapter must deliver these messages, and only these, to the:ownerpid:{:stdout, %{ref: ref}, data :: binary()} {:stderr, %{ref: ref}, data :: binary()} # absent in tty mode {:exit, %{ref: ref}, exit_code :: integer()} {:error, %{ref: ref}, reason :: term()} # transport failure; no :exit followswhere
refequals the returned command'srefand the second element is any map carrying:ref— consumers must match%{ref: ref}, never an adapter's struct. Exactly one terminal frame (:exitor:error) arrives, after all output frames. A stream that closes without an exit frame must be surfaced as{:exit, %{ref: ref}, 0}— an adapter that drops the connection silently makes failed commands look successful.write_stdin/2is total: writing to a command whose process has already exited returns{:error, :command_exited}, it never exits or raises in the caller (the #603 contract).attach/3re-joins a detached session and replays its buffered output from the beginning, then tails. There is no offset parameter; callers de-duplicate by counting bytes already persisted per stream, which only works if replay starts at byte zero.
Errors
Adapters normalize provider error shapes into the closed error/0
taxonomy so retry classification (Managoat.Sandbox.Retry.transient?/1) and
not-found handling are provider-neutral.
Summary
Types
What a provider can do beyond the required operations.
The provider-neutral error taxonomy.
The Fountain-minted, provider-scoped sandbox name.
A sandbox backend identifier.
Callbacks
Apply a deny-capable egress policy. allow: [] must deny all egress.
Re-join a detached session; replays buffered output from the start.
Build a handle from a persisted name. Pure — no I/O.
Capabilities this adapter currently offers (may be config-dependent).
Send stdin EOF.
Create (or adopt) the named sandbox.
Checkpoint the sandbox filesystem; returns the durable checkpoint id.
Destroy the sandbox. Already-gone is :ok.
Run a command to completion. Options: :env (list of {key, value}
pairs), :dir, :timeout (ms, default :infinity), :stderr_to_stdout.
Probe the sandbox. {:error, :not_found} is definitive absence.
Every sandbox name on the account, or a refusal — never a partial view.
List the sandbox's detachable sessions.
The provider atom this adapter serves.
The sandbox's HTTP endpoint, or {:error, :unsupported} where the platform
has no such concept.
Restore a checkpoint. A reported-failed restore is an error, not :ok.
Wake a parked sandbox, returning a fresh handle.
Start a streaming command. Options: :owner, :env, :dir, :stdin,
:tty, :detachable. Messages per the moduledoc contract.
Stop the local command handle, terminating its transport. Total — an
already-stopped command is :ok. For a detachable command the remote
process keeps running (that is what reattach exists for); this only tears
down this node's end.
Explicitly park the sandbox. No-op where the platform parks implicitly.
Write a file (creating parent directories). Options: :mode.
Write to the command's stdin. Total — see the moduledoc.
Functions
The adapter module for a provider. Raises on an unknown provider.
The adapter map: provider atom to the module implementing this behaviour.
Apply an egress policy.
Re-join a detached session.
Build a handle for a persisted sandbox name. Pure — no I/O.
Send stdin EOF to a running command.
Create (or adopt) a sandbox on the given provider.
Checkpoint the sandbox; returns the checkpoint id.
Destroy a sandbox.
Run a command to completion.
Probe a sandbox.
Resolve an in-sandbox path to the path a process inside the sandbox sees for it.
Every sandbox name the provider's account holds.
List a sandbox's detachable sessions.
The sandbox's HTTP endpoint.
Restore a checkpoint into the sandbox.
Wake a parked sandbox.
Start a streaming command.
Stop the local command handle. Total.
Whether a provider (or the provider owning a handle) has a capability.
Explicitly park a sandbox.
Write a file into the sandbox.
Write to a running command's stdin. Total.
Types
@type capability() ::
:suspend | :network_policy | :checkpoint | :attach | :tty | :public_url
What a provider can do beyond the required operations.
:suspend— idle sandboxes can park with their disk preserved at negligible cost (implicitly via scale-to-zero, or via an explicit pause/stop call insuspend/1); the idle sweep destroys instead where absent:network_policy— deny-capable egress policy:checkpoint— checkpoint create/restore currently usable:attach— detachable sessions with replay-from-start:tty— PTY allocation on spawn:public_url— the platform gives each sandbox an HTTP endpoint, and the adapter can report it (and make it reachable without a platform credential). Agents serve from inside the sandbox and need to be able to tell a human where to look
@type error() :: :not_found | :truncated | :not_supported | :command_exited | {:rate_limited, non_neg_integer() | nil} | {:unavailable, term()} | {:denied, term()} | {:invalid, term()} | {:restore_failed, term()} | {:write_failed, term()} | {:provider, provider(), term()}
The provider-neutral error taxonomy.
:not_found— the sandbox/session definitively does not exist:truncated— a listing refused to return a partial view:not_supported— the adapter does not implement this operation:command_exited— stdin write raced the command's exit{:rate_limited, retry_after}— throttled; transient{:unavailable, detail}— 5xx / timeout / transport; transient{:denied, detail}— 401/403; a credential problem, permanent{:invalid, detail}— other 4xx; the caller's fault, permanent{:restore_failed, detail}— a checkpoint restore reported failure{:write_failed, detail}— stdin write failed for a non-exit reason{:provider, provider, detail}— escape hatch; classified transient
@type info() :: %{status: :running | :suspended | :unknown, raw: term()}
Normalized sandbox info from get/1. :raw is provider-shaped.
@type name() :: String.t()
The Fountain-minted, provider-scoped sandbox name.
@type provider() :: atom()
A sandbox backend identifier.
Callbacks
@callback apply_network_policy( Managoat.Sandbox.Handle.t(), Managoat.Sandbox.NetworkPolicy.t() ) :: :ok | {:error, error()}
Apply a deny-capable egress policy. allow: [] must deny all egress.
@callback attach(Managoat.Sandbox.Handle.t(), session_id :: String.t(), keyword()) :: {:ok, Managoat.Sandbox.Command.t()} | {:error, error()}
Re-join a detached session; replays buffered output from the start.
@callback build_handle(name()) :: Managoat.Sandbox.Handle.t()
Build a handle from a persisted name. Pure — no I/O.
@callback capabilities() :: MapSet.t(capability())
Capabilities this adapter currently offers (may be config-dependent).
@callback close_stdin(Managoat.Sandbox.Command.t()) :: :ok | {:error, error()}
Send stdin EOF.
@callback create( name(), keyword() ) :: {:ok, Managoat.Sandbox.Handle.t()} | {:error, error()}
Create (or adopt) the named sandbox.
@callback create_checkpoint( Managoat.Sandbox.Handle.t(), keyword() ) :: {:ok, checkpoint_id :: String.t()} | {:error, error()}
Checkpoint the sandbox filesystem; returns the durable checkpoint id.
@callback destroy(Managoat.Sandbox.Handle.t()) :: :ok | {:error, error()}
Destroy the sandbox. Already-gone is :ok.
@callback exec( Managoat.Sandbox.Handle.t(), cmd :: String.t(), args :: [String.t()], keyword() ) :: {:ok, output :: binary(), exit_code :: integer()} | {:error, error()}
Run a command to completion. Options: :env (list of {key, value}
pairs), :dir, :timeout (ms, default :infinity), :stderr_to_stdout.
@callback get(Managoat.Sandbox.Handle.t()) :: {:ok, info()} | {:error, error()}
Probe the sandbox. {:error, :not_found} is definitive absence.
Every sandbox name on the account, or a refusal — never a partial view.
@callback list_sessions(Managoat.Sandbox.Handle.t()) :: {:ok, [Managoat.Sandbox.Session.t()]} | {:error, error()}
List the sandbox's detachable sessions.
@callback provider() :: provider()
The provider atom this adapter serves.
@callback public_url(Managoat.Sandbox.Handle.t()) :: {:ok, String.t()} | {:error, :unsupported | error()}
The sandbox's HTTP endpoint, or {:error, :unsupported} where the platform
has no such concept.
Adapters that advertise :public_url must return a URL a browser can open.
Everything else returns {:error, :unsupported} rather than a guess: a URL
that does not resolve is worse than none, because the agent will hand it to
a human who then blames the service they were told to visit.
@callback restore_checkpoint(Managoat.Sandbox.Handle.t(), checkpoint_id :: String.t()) :: :ok | {:error, error()}
Restore a checkpoint. A reported-failed restore is an error, not :ok.
@callback resume(Managoat.Sandbox.Handle.t()) :: {:ok, Managoat.Sandbox.Handle.t()} | {:error, error()}
Wake a parked sandbox, returning a fresh handle.
@callback spawn( Managoat.Sandbox.Handle.t(), cmd :: String.t(), args :: [String.t()], keyword() ) :: {:ok, Managoat.Sandbox.Command.t()} | {:error, error()}
Start a streaming command. Options: :owner, :env, :dir, :stdin,
:tty, :detachable. Messages per the moduledoc contract.
@callback stop_command(Managoat.Sandbox.Command.t()) :: :ok
Stop the local command handle, terminating its transport. Total — an
already-stopped command is :ok. For a detachable command the remote
process keeps running (that is what reattach exists for); this only tears
down this node's end.
@callback suspend(Managoat.Sandbox.Handle.t()) :: :ok | {:error, error()}
Explicitly park the sandbox. No-op where the platform parks implicitly.
@callback write_file(Managoat.Sandbox.Handle.t(), path :: String.t(), iodata(), keyword()) :: :ok | {:error, error()}
Write a file (creating parent directories). Options: :mode.
@callback write_stdin(Managoat.Sandbox.Command.t(), iodata()) :: :ok | {:error, error()}
Write to the command's stdin. Total — see the moduledoc.
Functions
The adapter module for a provider. Raises on an unknown provider.
The adapter map: provider atom to the module implementing this behaviour.
Defaults to the three adapters this library ships. A host registers its own (a self-hosted runner, an in-memory fake) by setting the whole map:
config :managoat_sandbox,
adapters: %{sprites: Managoat.Sandbox.Sprites, mine: MyApp.SandboxAdapter}Which of these a deployment may use is the host's policy, not the library's: a credential being present, an operator opt-out. The library answers only "which module serves this atom".
@spec apply_network_policy( Managoat.Sandbox.Handle.t(), Managoat.Sandbox.NetworkPolicy.t() ) :: :ok | {:error, error()}
Apply an egress policy.
@spec attach(Managoat.Sandbox.Handle.t(), String.t(), keyword()) :: {:ok, Managoat.Sandbox.Command.t()} | {:error, error()}
Re-join a detached session.
@spec build_handle(provider(), name()) :: Managoat.Sandbox.Handle.t()
Build a handle for a persisted sandbox name. Pure — no I/O.
@spec close_stdin(Managoat.Sandbox.Command.t()) :: :ok | {:error, error()}
Send stdin EOF to a running command.
@spec create(provider(), name(), keyword()) :: {:ok, Managoat.Sandbox.Handle.t()} | {:error, error()}
Create (or adopt) a sandbox on the given provider.
@spec create_checkpoint( Managoat.Sandbox.Handle.t(), keyword() ) :: {:ok, String.t()} | {:error, error()}
Checkpoint the sandbox; returns the checkpoint id.
@spec destroy(Managoat.Sandbox.Handle.t()) :: :ok | {:error, error()}
Destroy a sandbox.
@spec exec(Managoat.Sandbox.Handle.t(), String.t(), [String.t()], keyword()) :: {:ok, binary(), integer()} | {:error, error()}
Run a command to completion.
@spec get(Managoat.Sandbox.Handle.t()) :: {:ok, info()} | {:error, error()}
Probe a sandbox.
@spec host_path(Managoat.Sandbox.Handle.t(), String.t()) :: String.t()
Resolve an in-sandbox path to the path a process inside the sandbox sees for it.
On every hosted provider a sandbox is a real Linux box whose paths are
literal (/home/sprite is /home/sprite), so this is the identity. A
self-hosted runner (ADR 0022) maps /home/sprite onto a directory on the
user's machine, and a path an agent CLI validates in band — the ACP
cwd — must be the real one. Adapters that need the translation export
host_path/2; everything else gets the path back unchanged.
Every sandbox name the provider's account holds.
@spec list_sessions(Managoat.Sandbox.Handle.t()) :: {:ok, [Managoat.Sandbox.Session.t()]} | {:error, error()}
List a sandbox's detachable sessions.
@spec public_url(Managoat.Sandbox.Handle.t()) :: {:ok, String.t()} | {:error, :unsupported | error()}
The sandbox's HTTP endpoint.
{:error, :unsupported} when the provider has none — callers treat that as
"no URL to report", not as a failure. It is deliberately outside the shared
error taxonomy: every other error means something went wrong, and this one
means the question does not apply.
@spec restore_checkpoint(Managoat.Sandbox.Handle.t(), String.t()) :: :ok | {:error, error()}
Restore a checkpoint into the sandbox.
@spec resume(Managoat.Sandbox.Handle.t()) :: {:ok, Managoat.Sandbox.Handle.t()} | {:error, error()}
Wake a parked sandbox.
@spec spawn(Managoat.Sandbox.Handle.t(), String.t(), [String.t()], keyword()) :: {:ok, Managoat.Sandbox.Command.t()} | {:error, error()}
Start a streaming command.
@spec stop_command(Managoat.Sandbox.Command.t()) :: :ok
Stop the local command handle. Total.
@spec supports?(provider() | Managoat.Sandbox.Handle.t(), capability()) :: boolean()
Whether a provider (or the provider owning a handle) has a capability.
@spec suspend(Managoat.Sandbox.Handle.t()) :: :ok | {:error, error()}
Explicitly park a sandbox.
@spec write_file(Managoat.Sandbox.Handle.t(), String.t(), iodata(), keyword()) :: :ok | {:error, error()}
Write a file into the sandbox.
@spec write_stdin(Managoat.Sandbox.Command.t(), iodata()) :: :ok | {:error, error()}
Write to a running command's stdin. Total.