Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,25 @@ isolation remain separate work.
the pinned public API, or authorize unrelated refactors or live Session
configuration switching.

### Sandbox Provider contract

The canonical Sandbox Provider entry point is
[`sandbox_provider.go`](services/agents-api/internal/sandbox/sandbox_provider.go),
with integration and operation semantics in [the onboarding guide](docs/sandbox-provider.md).
Use `sandbox.SandboxProvider` for compute lifecycle and bounded bootstrap operations;
checkpoint and read-only observation remain separate optional interfaces.
Registration/construction owns vendor configuration. Core's common lifecycle
selects optional behavior by interface capability, not a provider name.

Retain the persisted allocation identity and cleanup responsibility after unknown
results. Never replay Create or initialization commands on transport failure.
Compute running, authenticated Runtime connection, prepared capabilities and Turn
acceptance are separate facts. A confirmed Kill cannot by itself settle an
outstanding Create. Capability preparation belongs to Runtime, and releasing an
executor does not reclaim its Environment. Do not add provider-specific execution
or preparation paths. New adapters must run the shared Sandbox Provider contract
suite through their native boundary and qualify their real resource behavior.

## Core–Runtime integration contract

The [Core–Runtime protocol](docs/runtime-protocol.md) is the integration entry
Expand Down
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,8 @@ check-e2b-provider:
.PHONY: check-docs
check-docs: node-deps
pnpm check:docs

# These packages are also exercised by check-agents-api in the full gate.
.PHONY: check-sandbox-provider-contract
check-sandbox-provider-contract:
go test ./services/agents-api/internal/sandbox/... -count=1
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ native differences.
| [Protocol coverage](contracts/agents-api/README.md) and [harness selection](contracts/agents-api/harness-selection.md) | Supported operations and native differences |
| [Core–Runtime protocol](docs/runtime-protocol.md) | Runtime integration, messages, receipts, failure ownership and contract checks |
| [Add a Harness](contracts/agents-api/harness-onboarding.md) | Required adapter interfaces, lifecycle, capabilities, registration and acceptance |
| [Add a Sandbox Provider](docs/sandbox-provider.md) | Required lifecycle, optional capabilities, registration, ownership and contract tests |
| [Landing page source](site/index.html) | The public site |

### Maintainers and advanced deployments
Expand Down
183 changes: 183 additions & 0 deletions docs/sandbox-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# Add a Sandbox Provider

Start at [`sandbox_provider.go`](../services/agents-api/internal/sandbox/sandbox_provider.go).
It defines the required `SandboxProvider` and optional `CheckpointProvider`
interfaces. Use the existing types; do not introduce another lifecycle protocol or
an execution path for a particular vendor.

Core owns durable Environment, allocation, placement and cleanup state. A Sandbox
Provider supplies compute and the bootstrap needed to start its Runtime (daemon).
Runtime prepares capabilities and workspaces through the common daemon protocol;
Harness adapters translate execution into native operations. The same Runtime
preparation and execution contract applies to a user-owned machine. That machine
has no Core-owned compute allocation to create or destroy.

Sandbox isolation belongs to the outer infrastructure. A daemon running directly
on a user's machine runs with that user's authority; installing the daemon does
not establish a filesystem, tool or network sandbox. Provider isolation and
network enforcement must be qualified independently of daemon connectivity.

## What to implement

| Contract | Requirement | Responsibility |
| --- | --- | --- |
| `sandbox.SandboxProvider` | Required | `Create`, `GetInfo`, `Renew`, `Kill`, and bounded bootstrap/diagnostic `RunCommand` |
| `sandbox.CheckpointProvider` | Optional, separate interface | Exact compute incarnations, snapshot capture/restore, retained-source resume and cleanup |
| `runtimeobs.Source` | Optional, separate interface | Read-only, ownership-checked resource observations |
| `runtimeobs.BatchSource` | Optional, separate interface | Bounded observations in input order, with per-target errors; `ok=false` means no batch read occurred |

Do not implement an optional interface with successful no-op methods. Core uses
interface assertions to select optional operations. Advertised support requires
contract and native acceptance evidence; a healthy node or an available CLI does
not establish it. Observation never renews a lease, starts compute or prepares a
Harness. See the [observation contract](../contracts/agents-api/runtime-observability.md).

`RunCommand` is an existing administrative bootstrap/diagnostic facility, not an
alternate route for Skills, Plugins, MCP setup, initial files, Session execution or
Files. Those use Runtime. Confidential command input travels in `Command.Stdin`,
not argv or logs. Preserve its byte order, bounded output and actual exit status.

## Register an adapter

Registration is explicit construction, not an init-time plugin registry:

1. Put native SDK calls, ownership checks, identity translation and private
configuration in an adapter package under `services/agents-api/internal/sandbox`.
Assert `var _ sandbox.SandboxProvider = (*YourAdapter)(nil)` at compile time.
2. For a node-local adapter, add its typed construction and readiness probe in
[`sandbox/config`](../services/agents-api/internal/sandbox/config/config.go).
The existing `Built` result returns the provider, probe, installation identity,
backend fingerprint and specification digest; the factory also returns its
close function. Construction must not create an Environment.
3. For a direct adapter, register its construction in
[`managedSetup.provider`](../services/agents-api/cmd/server/managed_setup.go).
Node-local adapters are selected there through the existing node proxy. Keep
vendor-specific deployment validation and SDK setup at this boundary.
4. Preserve the existing `execution.RuntimeProvider` deployment binding:
`ProviderKind`, installation ID, backend fingerprint, generation, mode and
node ownership identify the selected backend. The database owns the selection;
the in-memory selection is not an alternate authority. Register optional
interfaces on direct adapters and their transport wrappers consistently.
5. Qualify the provider's operator configuration and resource limits in the
existing deployment specification/enrollment path. The shipped deployment
parser and persistence constraints explicitly enumerate supported backends;
update that registration surface when adding one. Do not bypass it with an
arbitrary provider string. This is deployment admission, not a new execution
workflow. Adding a new public capability requires its own protocol change.

Today Docker and microsandbox use nodes; E2B is constructed directly. The node
proxy exposes checkpoint operations only for its registered checkpoint-capable
backend. A new node backend must register both construction and the corresponding
proxy capability; otherwise that capability is unavailable. Provider names belong
in this adapter/configuration wiring, not Session scheduling, capability
preparation or Turn execution. Common lifecycle code uses `CheckpointProvider`
to admit suspension, independently of a provider name.

The backend fingerprint identifies a native resource namespace, not mutable
capacity. Core retains deployment generations so old owned allocations continue
to resolve to their original backend. This is resource ownership, not historical
binary compatibility. Do not repoint retained allocations at a replacement backend.

## Identity and resource responsibility

`Reference` is the exact `(TenantID, EnvironmentID, AllocationID)` tuple. Core
persists a fresh allocation ID **before** `Create`; it is not the Environment ID.
The adapter also binds resources to its installation. Do not locate or authorize
resources by a bare native ID, display name, guessed path or an unverified label.
All mutations, reads and cleanup must verify the same ownership.

Core serializes lifecycle operations and retains the allocation after any
uncertain mutation. An adapter must preserve enough native identity/receipts for
observation and cleanup. A failed call may return both `Info` and an error:
preserve reference-bound settlement evidence without converting failure to success.
Adapters must not silently create replacement resources, overwrite credentials or
switch to a new allocation after a conflict.

`Info.ProviderID` and `Info.State` describe compute. Core recognizes `running`
only with the matching Reference and a native identity. Other native states can
be observed without claiming readiness. `BootstrapComplete` says the bootstrap
reached its final mutating step. `CreateSettled` proves the original attempt can
no longer mutate resources; it does not mean success. In particular:

- `State="absent"` plus matching Reference and `CreateSettled=true` is an explicit
creation-absence receipt, with no native ID or completed bootstrap.
- `ErrNotFound`, an empty listing, a timeout or a successful `Kill` alone is not
proof that an in-flight create cannot appear later.
- Core can also settle creation from a matching running resource with completed
bootstrap. It must retain unknown creation until it has such evidence or an
explicit receipt, even if a cleanup attempt currently sees no resources.

`Kill` owns cleanup of the allocation's compute and retained resources, including
partial bootstrap storage. It must not remove another tenant's resource on a
name collision. Core releases durable ownership only after confirmed cleanup
**and** settled creation. Executor release or Harness cancellation does not delete
an Environment, its workspace or its Sandbox Provider allocation.

## Operation outcomes and retries

All calls receive bounded contexts. Expiry or cancellation ends the caller's
wait; it does not prove rollback, native stop, cleanup or absence. An adapter or
transport must not detach untracked mutations or replay a timed-out command.

| Operation | Confirmed result | Failure or unknown result | Recovery |
| --- | --- | --- | --- |
| `Create` | Matching compute and bootstrap evidence; execution still needs Runtime preparation | Invalid/foreign configuration rejects; duplicate returns `ErrExists`; transport failure may hide created resources | Observe the original Reference. Never replay `Create`, even with a new credential. Preserve partial resources for owned cleanup. |
| `GetInfo` | Current compute observation without mutation | `ErrNotFound` is only a missing observation; an error is not absence proof | Repeat a bounded read; never turn it into create/start/renew. |
| `Renew` | Existing native lease extended, or an observation for a provider without leases | Timeout may hide a lease extension; stopped/missing compute stays stopped/missing | Observe then let the normal reconciler renew the same allocation. Never revive it or fabricate a lease expiry. |
| `Kill` | Owned compute and retained storage removed; repeated confirmed absence succeeds | Error retains ownership and cleanup intent; ownership mismatch must not delete foreign resources | Retry cleanup of the same Reference after outstanding creation/mutation is fenced. Do not release the durable owner early. |
| `RunCommand` | Collected output and actual exit code; nonzero exit is a settled command failure | Missing native completion is `ErrCommandUnconfirmed`; partial output is not success | Do not replay. Preserve the owner and reclaim before reuse when completion cannot be proved. |

Use `ErrInvalid`, `ErrOwnership`, `ErrExists`, `ErrNotFound`,
`ErrComputeUnconfirmed` and `ErrCommandUnconfirmed` for their existing meanings.
An unclassified native/transport error is conservatively unknown, not permission
to retry a mutation. Core must not interpret provider diagnostics as lifecycle
truth or expose native error text/credentials. The node boundary maps errors to
fixed codes; direct SDK details stay private.

Checkpoint support adds `Compute` generation/name/ID and `SnapshotIdentity`.
Persist operation IDs and provider-returned snapshot provenance unchanged.
`ObserveOnly` on suspend/resume observes the previous attempt and must not start
another capture or restore. `ResumeCompute` only thaws the retained source; it
must not cold-start a stopped one. Cleanup targets the exact compute incarnation
and snapshot, not whichever instance currently has the same display name. See
[the lifecycle implementation](../services/agents-api/internal/execution/runtime_compute.go)
and its failure tests before advertising this capability.

## Four distinct readiness facts

| Fact | Evidence | Does not establish |
| --- | --- | --- |
| Compute available | Provider observation for the owned allocation | Authenticated Runtime connection or prepared capabilities |
| Runtime connected | Gateway authentication and exact Environment/device binding | Completed Runtime preparation or a usable Harness |
| Capabilities prepared | Successful common Runtime preparation with the fixed configuration/snapshot | Acceptance or completion of a Turn |
| Execution admitted | Qualified Harness capabilities and the existing executor/Turn acceptance path | A completed input, cancellation or reclaimed compute |

For both hosted and self-hosted environments, Core uses the daemon preparation
and execution protocol. Core resolves configuration and supplies resources;
Runtime installs/reads local capabilities and freezes the result for the Session.
Reconnection can reuse that prepared content; a new Session takes a new snapshot.
A provider must not implement a competing preparation path.

## Validate the integration

Run `make check-sandbox-provider-contract` while developing. It runs the shared
[`contracttest`](../services/agents-api/internal/sandbox/contracttest) suite through
real adapter boundaries using controlled native failures, plus existing adapter
and node transport tests. The same packages are included in `make check`.
New adapters should call the public failure runner with native-side fixtures,
not substitute a fake implementation of `SandboxProvider` for the adapter under
test. Preserve tests for foreign ownership, unknown mutation results, cancellation,
no automatic replay, failed cleanup and reference-bound settlement.

Node tests separately exercise disconnect/reconnect fencing and cleanup after a
lost create response. Provider helper protocols and the node protocol require an
exact version match and reject mismatches; do not add fallback decoders or old
binary migration. Direct in-process interfaces have no independent wire version.

Run `make check` with its dedicated database before completion. Retain native
acceptance for SDK behavior that fixtures cannot prove: creation, lease behavior,
owned partial cleanup, declared isolation/limits, and snapshots where supported.
The opt-in Docker lifecycle/recovery tests use `AGENTS_RUNTIME_DOCKER_TEST_IMAGE`;
SDK helper tests use `make check-e2b-provider` and
`make check-microsandbox-provider`. Passing controlled contract tests is not a
claim of live cloud or model acceptance.
2 changes: 1 addition & 1 deletion services/agents-api/cmd/server/managed_generations.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ type e2bGenerationRouter struct {
store generationStore
}

func (p *e2bGenerationRouter) route(ctx context.Context, r sandbox.Reference) (sandbox.Provider, func(), error) {
func (p *e2bGenerationRouter) route(ctx context.Context, r sandbox.Reference) (sandbox.SandboxProvider, func(), error) {
release, err := p.setup.e2bCalls.Enter(ctx)
if err != nil {
return nil, nil, err
Expand Down
4 changes: 2 additions & 2 deletions services/agents-api/cmd/server/managed_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ func (s *managedSetup) configuration(setup store.SandboxSetup) (execution.Prepar
}
selected := &execution.RuntimeProvider{InstallationID: setup.InstallationID, ProviderKind: setup.Provider, Generation: setup.Generation, Mode: setup.Mode, AdmissionPaused: setup.AdmissionPaused,
CoreURL: s.publicURL + "/api/v1", BackendFingerprint: setup.BackendFingerprint, Provider: provider}
if setup.Provider == "microsandbox" {
if _, supportsCheckpoint := provider.(sandbox.CheckpointProvider); supportsCheckpoint {
selected.Suspension = &execution.RuntimeSuspensionPolicy{IdleTimeout: time.Duration(setup.IdleSeconds) * time.Second,
Retention: time.Duration(setup.RetentionSeconds) * time.Second, MaxActive: 4, MaxRetained: 16}
}
Expand Down Expand Up @@ -176,7 +176,7 @@ func (s *managedSetup) Observe(ctx context.Context, target runtimeobs.Target) (r
return source.Observe(ctx, target)
}

func (s *managedSetup) provider(setup store.SandboxSetup) (sandbox.Provider, error) {
func (s *managedSetup) provider(setup store.SandboxSetup) (sandbox.SandboxProvider, error) {
switch setup.Provider {
case "docker", "microsandbox":
if s.hub == nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import (
// Provider callbacks inspect the real database at the instant destructive
// cleanup is invoked. They do not create containers or claim native evidence.
type waitingCleanupProvider struct {
sandbox.Provider
sandbox.SandboxProvider
beforeKill func()
}

Expand Down
2 changes: 1 addition & 1 deletion services/agents-api/internal/execution/runtime_adoption.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (

// VerifyLegacyRuntimeOwnership uses only positive, read-only Provider observations.
// It runs before node placement and never becomes an execution fallback route.
func VerifyLegacyRuntimeOwnership(ctx context.Context, p sandbox.Provider, allocation store.RuntimeAllocation) (err error) {
func VerifyLegacyRuntimeOwnership(ctx context.Context, p sandbox.SandboxProvider, allocation store.RuntimeAllocation) (err error) {
failure := "resource_unconfirmed"
defer func() {
if err == nil {
Expand Down
4 changes: 2 additions & 2 deletions services/agents-api/internal/execution/runtime_lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ type RuntimeProvider struct {
CoreURL string
InstallationID string
BackendFingerprint string
Provider sandbox.Provider
Provider sandbox.SandboxProvider
AdmissionPaused bool
Suspension *RuntimeSuspensionPolicy
}
Expand Down Expand Up @@ -95,7 +95,7 @@ func validatedRuntimeProvider(config *RuntimeProvider, registry *gateway.Registr
if copied.Mode != "" && copied.Mode != "nodes" && copied.Mode != "direct" {
return RuntimeProvider{}, sandbox.ErrInvalid
}
if copied.Mode == "direct" && (copied.ProviderKind != "e2b" || copied.LocalNodeID != "" || copied.Suspension != nil) {
if copied.Mode == "direct" && (copied.ProviderKind == "" || copied.LocalNodeID != "" || copied.Suspension != nil) {
return RuntimeProvider{}, sandbox.ErrInvalid
}
if config.Suspension != nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ func TestFailedInventoryRetirementClosesAdmissionAndRetainsGate(t *testing.T) {
}

// No provider method is called by the retirement ownership fixture.
type drainFixtureProvider struct{ sandbox.Provider }
type drainFixtureProvider struct{ sandbox.SandboxProvider }

// Done is evaluated only after lockMutation's initial admission check, letting
// this test close admission while a request is already waiting for its gate.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ func (m *runtimeManager) ensureDeployment(parent context.Context) (bool, error)
if err != nil || config == nil {
return false, err
}
if config.InstallationID != m.setupInstallationID || config.LocalNodeID != "" || config.loadDeployment != nil || (config.ProviderKind != "docker" && config.ProviderKind != "microsandbox" && config.ProviderKind != "e2b") {
if config.InstallationID != m.setupInstallationID || config.LocalNodeID != "" || config.loadDeployment != nil || config.ProviderKind == "" {
return false, sandbox.ErrInvalid
}
copied, err := validatedRuntimeProvider(config, m.registry)
Expand Down
Loading
Loading