Managoat.Sandbox behaviour (managoat_sandbox v0.1.0)

Copy Markdown View Source

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/2 is name-keyed and idempotent-adopting: creating a name that already exists returns {:ok, handle} for the existing sandbox.
  • get/1 must 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/1 tolerates an already-gone sandbox (:ok).
  • list_all_names/0 returns the full account view or refuses with {:error, :truncated} — never a partial set that looks whole.
  • suspend/1 / resume/1 park 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/4 blocks 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/4 starts a streaming command. The adapter must deliver these messages, and only these, to the :owner pid:

    {: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 follows

    where ref equals the returned command's ref and the second element is any map carrying :ref — consumers must match %{ref: ref}, never an adapter's struct. Exactly one terminal frame (:exit or :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/2 is 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/3 re-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.

Normalized sandbox info from get/1. :raw is provider-shaped.

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

capability()

@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 in suspend/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

error()

@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

info()

@type info() :: %{status: :running | :suspended | :unknown, raw: term()}

Normalized sandbox info from get/1. :raw is provider-shaped.

name()

@type name() :: String.t()

The Fountain-minted, provider-scoped sandbox name.

provider()

@type provider() :: atom()

A sandbox backend identifier.

Callbacks

apply_network_policy(t, t)

@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.

attach(t, session_id, keyword)

@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.

build_handle(name)

@callback build_handle(name()) :: Managoat.Sandbox.Handle.t()

Build a handle from a persisted name. Pure — no I/O.

capabilities()

@callback capabilities() :: MapSet.t(capability())

Capabilities this adapter currently offers (may be config-dependent).

close_stdin(t)

@callback close_stdin(Managoat.Sandbox.Command.t()) :: :ok | {:error, error()}

Send stdin EOF.

create(name, keyword)

@callback create(
  name(),
  keyword()
) :: {:ok, Managoat.Sandbox.Handle.t()} | {:error, error()}

Create (or adopt) the named sandbox.

create_checkpoint(t, keyword)

@callback create_checkpoint(
  Managoat.Sandbox.Handle.t(),
  keyword()
) :: {:ok, checkpoint_id :: String.t()} | {:error, error()}

Checkpoint the sandbox filesystem; returns the durable checkpoint id.

destroy(t)

@callback destroy(Managoat.Sandbox.Handle.t()) :: :ok | {:error, error()}

Destroy the sandbox. Already-gone is :ok.

exec(t, cmd, args, keyword)

@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.

get(t)

@callback get(Managoat.Sandbox.Handle.t()) :: {:ok, info()} | {:error, error()}

Probe the sandbox. {:error, :not_found} is definitive absence.

list_all_names()

@callback list_all_names() :: {:ok, MapSet.t(name())} | {:error, error()}

Every sandbox name on the account, or a refusal — never a partial view.

list_sessions(t)

@callback list_sessions(Managoat.Sandbox.Handle.t()) ::
  {:ok, [Managoat.Sandbox.Session.t()]} | {:error, error()}

List the sandbox's detachable sessions.

provider()

@callback provider() :: provider()

The provider atom this adapter serves.

public_url(t)

@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.

restore_checkpoint(t, checkpoint_id)

@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.

resume(t)

@callback resume(Managoat.Sandbox.Handle.t()) ::
  {:ok, Managoat.Sandbox.Handle.t()} | {:error, error()}

Wake a parked sandbox, returning a fresh handle.

spawn(t, cmd, args, keyword)

@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.

stop_command(t)

@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.

suspend(t)

@callback suspend(Managoat.Sandbox.Handle.t()) :: :ok | {:error, error()}

Explicitly park the sandbox. No-op where the platform parks implicitly.

write_file(t, path, iodata, keyword)

@callback write_file(Managoat.Sandbox.Handle.t(), path :: String.t(), iodata(), keyword()) ::
  :ok | {:error, error()}

Write a file (creating parent directories). Options: :mode.

write_stdin(t, iodata)

@callback write_stdin(Managoat.Sandbox.Command.t(), iodata()) :: :ok | {:error, error()}

Write to the command's stdin. Total — see the moduledoc.

Functions

adapter_for(provider)

@spec adapter_for(provider()) :: module()

The adapter module for a provider. Raises on an unknown provider.

adapters()

@spec adapters() :: %{required(provider()) => module()}

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".

apply_network_policy(handle, policy)

@spec apply_network_policy(
  Managoat.Sandbox.Handle.t(),
  Managoat.Sandbox.NetworkPolicy.t()
) ::
  :ok | {:error, error()}

Apply an egress policy.

attach(handle, session_id, opts \\ [])

@spec attach(Managoat.Sandbox.Handle.t(), String.t(), keyword()) ::
  {:ok, Managoat.Sandbox.Command.t()} | {:error, error()}

Re-join a detached session.

build_handle(provider, name)

@spec build_handle(provider(), name()) :: Managoat.Sandbox.Handle.t()

Build a handle for a persisted sandbox name. Pure — no I/O.

close_stdin(command)

@spec close_stdin(Managoat.Sandbox.Command.t()) :: :ok | {:error, error()}

Send stdin EOF to a running command.

create(provider, name, opts \\ [])

@spec create(provider(), name(), keyword()) ::
  {:ok, Managoat.Sandbox.Handle.t()} | {:error, error()}

Create (or adopt) a sandbox on the given provider.

create_checkpoint(handle, opts \\ [])

@spec create_checkpoint(
  Managoat.Sandbox.Handle.t(),
  keyword()
) :: {:ok, String.t()} | {:error, error()}

Checkpoint the sandbox; returns the checkpoint id.

destroy(handle)

@spec destroy(Managoat.Sandbox.Handle.t()) :: :ok | {:error, error()}

Destroy a sandbox.

exec(handle, cmd, args, opts \\ [])

@spec exec(Managoat.Sandbox.Handle.t(), String.t(), [String.t()], keyword()) ::
  {:ok, binary(), integer()} | {:error, error()}

Run a command to completion.

get(handle)

@spec get(Managoat.Sandbox.Handle.t()) :: {:ok, info()} | {:error, error()}

Probe a sandbox.

host_path(handle, path)

@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.

list_all_names(provider)

@spec list_all_names(provider()) :: {:ok, MapSet.t(name())} | {:error, error()}

Every sandbox name the provider's account holds.

list_sessions(handle)

@spec list_sessions(Managoat.Sandbox.Handle.t()) ::
  {:ok, [Managoat.Sandbox.Session.t()]} | {:error, error()}

List a sandbox's detachable sessions.

public_url(handle)

@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.

restore_checkpoint(handle, checkpoint_id)

@spec restore_checkpoint(Managoat.Sandbox.Handle.t(), String.t()) ::
  :ok | {:error, error()}

Restore a checkpoint into the sandbox.

resume(handle)

@spec resume(Managoat.Sandbox.Handle.t()) ::
  {:ok, Managoat.Sandbox.Handle.t()} | {:error, error()}

Wake a parked sandbox.

spawn(handle, cmd, args, opts \\ [])

@spec spawn(Managoat.Sandbox.Handle.t(), String.t(), [String.t()], keyword()) ::
  {:ok, Managoat.Sandbox.Command.t()} | {:error, error()}

Start a streaming command.

stop_command(command)

@spec stop_command(Managoat.Sandbox.Command.t()) :: :ok

Stop the local command handle. Total.

supports?(provider, capability)

@spec supports?(provider() | Managoat.Sandbox.Handle.t(), capability()) :: boolean()

Whether a provider (or the provider owning a handle) has a capability.

suspend(handle)

@spec suspend(Managoat.Sandbox.Handle.t()) :: :ok | {:error, error()}

Explicitly park a sandbox.

write_file(handle, path, data, opts \\ [])

@spec write_file(Managoat.Sandbox.Handle.t(), String.t(), iodata(), keyword()) ::
  :ok | {:error, error()}

Write a file into the sandbox.

write_stdin(command, data)

@spec write_stdin(Managoat.Sandbox.Command.t(), iodata()) :: :ok | {:error, error()}

Write to a running command's stdin. Total.