02 — Port Interfaces¶
Protocols adapters implement. Structural (typing.Protocol), so third parties need not
import a base class. Type-checking conformance is necessary but not sufficient — the
contract suite is what proves correctness (08).
Backend¶
class Backend(Protocol):
name: str
capabilities: Capability
isolation: IsolationTier
async def create(
self, *,
template: str | None = None,
resources: Resources = Resources(),
network: NetworkPolicy = NetworkPolicy(),
env: dict[str, str] | None = None,
secrets: dict[str, str] | None = None,
timeout: float = 300,
metadata: dict[str, str] | None = None,
) -> AsyncSandbox: ...
async def connect(self, sandbox_id: str) -> AsyncSandbox: ...
create()MUST NOT return until the sandbox is usable, or MUST raise.create()MUST applynetworkandresourcesbefore any user code can run. A backend that can only apply policy post-creation MUST raiseCapabilityNotSupportedrather than create an unpoliced sandbox.connect()MUST raiseConnectErrorfor an unknown or dead id; it MUST NOT create one.metadataMUST be propagated to provider-native labels where the provider supports them, so external reconciliation and reaping can find orphans.
ReapableBackend (optional)¶
class ReapableBackend(Backend, Protocol):
async def list_managed(self, *, labels: Mapping[str, str] | None = None) -> list[ManagedSandbox]: ...
async def kill_managed(self, sandbox_id: str) -> bool: ...
What sandboxio reap (10) talks to. Optional for third parties,
implemented by every first-party backend including the fake. list_managed() MUST return
sandboxes the provider still holds under sandboxio's label, including ones whose lifetime
already ended where the provider keeps them (Docker's stopped containers); labels narrows
by metadata. kill_managed() returns False when the id is already gone and MUST NOT
raise for that case.
AsyncSandbox¶
class AsyncSandbox(Protocol):
id: str
capabilities: Capability
isolation: IsolationTier
async def run(self, cmd: str | list[str], *,
timeout: float | None = None,
env: dict[str, str] | None = None) -> ExecResult: ...
async def run_code(self, code: str, *,
language: str = "python",
context_id: str | None = None,
timeout: float | None = None) -> ExecResult: ...
def stream(self, cmd: str | list[str], *,
timeout: float | None = None,
env: dict[str, str] | None = None) -> Process: ... # NOT a coroutine
async def kill(self) -> None: ...
@property
def files(self) -> AsyncFileSystem: ...
@property
def native(self) -> object: ...
async def __aenter__(self) -> AsyncSandbox: ...
async def __aexit__(self, *exc) -> None: ...
Requirements:
- No
**kwargsanywhere in the public surface. Every argument is named and typed. runwith alist[str]MUST NOT go through a shell. With astrit MAY, and the behaviour MUST be documented per adapter.timeout=Nonemeans "inherit the sandbox timeout", never "unbounded" (05).- A timeout MUST raise
ExecutionTimeout, and MUST NOT hang or return a partial result as success. A sandbox that has ceased to exist MUST raiseSandboxGone, not a timeout (04). run_codewithcontext_idrequiresCapability.STATEFUL_CODE; without it, MUST raiseCapabilityNotSupported. Docker declares it off in v0.1 (ADR-0024).kill()MUST be idempotent. A second call on a dead sandbox MUST succeed silently.nativeMUST return the live provider object with nothing wrapped or hidden. It is outside the semver contract and every reference to it in docs MUST say so.
Process (streaming)¶
Resolved in ADR-0019.
@dataclass(frozen=True)
class OutputChunk:
stream: Literal["stdout", "stderr"]
data: bytes
class Process(Protocol):
async def __aenter__(self) -> Process: ...
async def __aexit__(self, *exc) -> None: ...
def __aiter__(self) -> AsyncIterator[OutputChunk]: ...
async def wait(self) -> ExecResult: ...
async def kill(self) -> None: ...
@property
def returncode(self) -> int | None: ... # None while running
async with sb.stream(["pytest", "-q"], timeout=300) as proc:
async for chunk in proc:
log.write(chunk.data)
res = await proc.wait()
Requirements:
stream()MUST be a plain function and its return value MUST NOT be awaitable, so omittingasync withfails immediately rather than leaking. The process starts on__aenter__.__aexit__MUST terminate the process if still running — after normal completion, an earlybreak, a propagating exception, or cancellation. Shielding and grace period per Q7.- Ordering MUST be preserved within each stream. Ordering between stdout and stderr is explicitly NOT guaranteed.
wait()MUST return the terminalExecResultwithstreamed=Trueand emptystdout/stderr. It MUST be idempotent. Called with output unconsumed, it drains and discards the remainder.- Missing
Capability.STREAMINGMUST raiseCapabilityNotSupportedfromstream()itself, before the context is entered. - A timeout MUST raise
ExecutionTimeoutfrom iteration or fromwait(). - Typed keyword arguments only; no
**kwargs.
stream_code is deferred. Process is shaped to carry interpreter rich outputs later;
streaming code execution lands when a second backend supports it, per the two-backend
promotion rule (ADR-0003). Until then it is
reachable through .native.
AsyncFileSystem¶
class AsyncFileSystem(Protocol):
async def read(self, path: str) -> bytes: ...
async def write(self, path: str, data: bytes | str) -> None: ...
async def upload(self, local: str | Path, remote: str) -> None: ...
async def download(self, remote: str, local: str | Path) -> None: ...
async def ls(self, path: str = ".") -> list[FileInfo]: ...
async def mkdir(self, path: str, *, parents: bool = False) -> None: ...
async def remove(self, path: str) -> None: ...
- All paths are sandbox-internal. An adapter MUST NOT resolve a path against the host
filesystem, except for the explicit
localarguments ofupload/download. - A relative path MUST resolve against the same sandbox root the adapter gives
run(), so writing"out.txt"and thencat out.txtname one file. An adapter whose provider APIs disagree resolves the path itself rather than passing it through. readreturnsbytes; text decoding is the caller's. Round-trips MUST be binary-safe.- A missing path MUST raise a mapped sandboxio error, never return empty.
- Large transfers SHOULD stream rather than buffer whole files in memory.
Cancellation and teardown¶
Resolved in ADR-0020. Under structured concurrency
a plain finally: await self.kill() is decorative — it is cancelled at its first
checkpoint — so teardown MUST be shielded.
- Teardown MUST run in a shielded, bounded scope.
TEARDOWN_GRACEdefaults to 5 s, overridable bySBX_TEARDOWN_GRACE. It is NOT acreate()parameter. - Cancelling a task awaiting
run(),run_code()or a stream MUST NOT leave an orphaned remote process. Cancellation kills the remote process; detach-on-cancel is deferred. - Partial creation MUST be shielded narrowly: the window between the provider returning
an id and the handle owning it, and no wider.
create()as a whole MUST NOT be shielded. - If the grace expires,
OrphanedSandboxWarningMUST be emitted naming the sandbox id, backend andmetadatalabels. - Cancellation MUST propagate as cancellation.
CancelledError/anyio.get_cancelled_exc_class()MUST NOT be wrapped in aSandboxError;except BaseExceptionin an adapter is a bug. kill()MUST be safe to call from inside a shielded scope, MUST be idempotent, and MUST NOT block indefinitely.- The provider-side timeout from
create(timeout=...)is the guaranteed backstop: it bounds the worst-case orphan even when every other mechanism fails (05). - Each of the above is a contract-suite test, not adapter discretion.