Skip to content

Latest commit

 

History

31 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@agent-relay/sandbox

Provider-agnostic sandbox runtimes and orchestration for agent workloads.

Agents that write code need somewhere to run it. This package provides a single runtime port that several sandbox providers implement, plus the orchestration layer that drives a sandbox through a workload: launch it, put files into it, run commands, stream results back, and tear it down.

The point of the port is that the orchestration layer holds no provider knowledge. Swapping providers is a change of adapter, not a change of caller.

Status: pre-1.0. The public API may still change before a 1.0.0 release.

Install

npm install @agent-relay/sandbox

Provider SDKs are peer dependencies: install the one you intend to use. A consumer that only runs local sandboxes does not need a remote provider SDK. Adapters for providers that publish no JavaScript SDK speak their HTTP API directly and add no dependency at all; they take an injectable fetch instead.

Import provider-neutral APIs and the selected adapter from separate entrypoints:

import { SandboxOrchestrator } from "@agent-relay/sandbox/core";
import { DaytonaRuntime } from "@agent-relay/sandbox/daytona";

The provider entrypoints are agent37, agentcore, daytona, e2b, freestyle, local, microsandbox, modal, and vercel. Each points at its own barrel, so a bundler does not have to resolve the other optional provider SDKs. The root @agent-relay/sandbox barrel still exports the complete historical surface for compatibility, but new runtime imports should use core plus one provider subpath.

The export map uses ./* -> ./dist/*/index.js rather than enumerating today’s providers. To add another provider safely, add src/<provider>/index.ts as its isolated public barrel and declare its SDK as an optional peer. The package contract test requires every runtime directory (and every optional peer owner) to have such a barrel, and the packed-package smoke test verifies those barrels ship and import without installing any optional peers.

Provider constraints

Each adapter inherits its provider SDK's requirements, and they are not all the same as this package's:

Adapter Peer dependency Requirements beyond this package's
DaytonaRuntime @daytonaio/sdk 0.180–0.205
E2BSandboxRuntime e2b
MicrosandboxRuntime microsandbox Node.js 22+, a platform-specific native addon (macOS arm64, Linux x64/arm64, Windows x64/arm64), and — for its local backend — hardware virtualization: KVM on Linux, Apple Silicon on macOS, or WHP on Windows 10+
LocalSandboxRuntime A reachable local sandbox service

The package itself keeps a Node 20 floor, because a consumer that never touches the microsandbox adapter never loads that SDK: it is imported lazily, at first use, and a load failure is reported with the constraint that most often explains it.

Microsandbox capabilities are backend-sensitive

MicrosandboxRuntime.capabilities is derived from the backend the instance is bound to, not reported as a single process-wide constant:

Capability local cloud Why
snapshots true false A snapshot is a host-local artifact: the installed SDK's typings describe Snapshot as an artifact on disk and resolve one under ~/.microsandbox/snapshots/<name>/. This adapter consumes such an artifact from the calling host and never transfers it, so a create issued against a remote backend has nothing to resolve. Configuring snapshot with a cloud backend is refused in the constructor, before any SDK call.
isolation 'strong' 'unknown' Locally the SDK boots a microVM with its own guest kernel on a virtualization-capable host, and the installed package states that requirement itself, so 'strong' rests on something checkable here. This adapter observes and measures nothing about the cloud backend's isolation.

Both values describe what this package has established, not what any provider documents. 'unknown' is not a synonym for weak and is not a claim that the guarantee is missing — it means this package has not established one, so a caller that requires a specific guarantee must decide for itself rather than read an unverified 'strong'.

Cloud region placement and resource enforcement are likewise not represented as measured facts. Custom or published ports are not supported: the SDK builder exposes port()/portBind(), but the ports this package targets have no public-port surface, so the adapter never calls them and never implies a reachable port.

Design

Two pieces, deliberately kept apart:

  • Runtime adapters implement a small port — launch, exec, file transfer, teardown — against one provider apiece.
  • The orchestrator drives a workload through whichever runtime it is given. It is generic over the provider's handle type and knows nothing about any specific provider.

Configuration is injected, never baked in. The package ships no default templates, endpoints, hostnames, or credentials: anything environment-specific is a required argument supplied by the caller. This keeps the package usable outside the environment it was extracted from, and keeps credential handling in the caller where it belongs.

Each adapter also declares what it genuinely supports rather than what its method names imply. resolveSandboxRuntimeCapabilities reads that declaration, so a caller learns up front whether a provider can reattach to a sandbox by id, poll a background command, hand back a still-booting sandbox, or search by label — instead of discovering the answer from a failure at run time.

Daytona restart recovery

DaytonaRuntime.start() does not trust the provider state transition alone. It rehydrates the SDK sandbox and runs a bounded true readiness probe after start, because Daytona can report STARTED while its Toolbox exec daemon remains unavailable. A healthy restart keeps the same sandbox ID. A failure during that rehydration itself (auth, rate limit, or network) is not proof the exec daemon is dead, so it is propagated as-is and never triggers a replacement.

When the post-start readiness probe fails, the runtime defaults to creating and proving a replacement before deleting the unusable sandbox. The returned handle is updated in place and can therefore have a new id; callers must persist that returned ID. The replacement preserves the configured snapshot plus provider labels, environment, lifecycle, volume, and network settings, but non-volume filesystem changes in the old sandbox are not copied. Stateful callers that prefer a hard failure to that trade-off can set recreateOnFailedStart: false.

E2B runtime contract

E2BSandboxRuntime implements both the outer orchestration port and the live WorkflowRuntime surface: metadata/state lookup, reattachment, synchronous and durable asynchronous execution, upload/download, home-directory resolution, pause/resume, and owned-resource teardown. It deliberately does not expose a detached-launch method because E2B's public create call waits for a running sandbox. Its live capability descriptor likewise reports PTY and streaming logs as unsupported because this adapter exposes neither behavior, even though the provider SDK has lower-level APIs for them.

The adapter applies an explicit sandbox lifetime on create, reconnect, and synchronous use; sandboxLifetimeMs defaults to the configured asynchronous run budget.

Synchronous runs always carry an explicit command lifetime cap, because E2B — alone among the providers here — applies a 60-second default when the field is omitted, while the orchestrator omits timeoutMs on most execs. A run without a caller timeout therefore uses syncRunBudgetMs, which defaults to the configured runBudgetMs (30 minutes unless set). An explicit caller timeout always wins, and is the only thing that extends the sandbox past sandboxLifetimeMs; the implicit budget never does.

Asynchronous session IDs are immutable idempotency keys. A retry reconciles the durable admission record with E2B's process list and will not erase the session directory or submit another copy. Status is pending only while the matching provider process is present. If that process disappears without publishing its exit sidecar, status becomes terminal with E2B_ASYNC_PROCESS_LOST_EXIT_CODE (255).

Daytona wire-supplement

Daytona's Sandbox wire response carries two fields — sandboxClass (the sandbox's class/tier) and warmPoolId (set while a sandbox is an unclaimed warm-pool member) — that exist on the low-level @daytona/api-client DTOs but that the vendored @daytonaio/sdk's Sandbox class does not copy onto itself. DaytonaRuntime.getWireSupplement(handle) fetches both directly via the SDK's low-level sandboxApi, the same reach pattern runtime.ts already uses for detached create.

This is a narrow, deliberately temporary gap, not a fork of the SDK: the other fields once suspected missing (autoDestroyAt, autoPauseInterval, spot) already ship on the public Sandbox class as of @daytonaio/sdk 0.200.0–0.205.0 — a dependency bump alone covers those. Tracked upstream at daytona/clients#207 (precedent: #192, which added spot the same way). Retire src/daytona/wire-supplement.ts once processSandboxDto() copies sandboxClass/warmPoolId and a subsequent SDK bump picks that up — runtime.test.ts's DaytonaRuntime smoke suite has a load-bearing regression test that fails once that happens.

Freestyle runtime contract

FreestyleRuntime uses an explicit API key, home directory, ownership-name prefix, and persistence policy. It never reads ambient credentials. Freestyle does not expose labels on VM creation, so label lookup and warm leasing remain unsupported; ownership and cleanup are scoped to collision-safe names under the configured prefix. Deleted list rows are treated as gone only when the provider sets deleted: true.

The adapter exposes buffered exec, file transfer, reattachment, owned-resource listing, and verified deletion. Stop/start methods exist as a conservative probe surface, but lifecycle remains undeclared because the live validation account could not create a persistent VM. PTY, snapshots, streaming logs, fork, and never-idle behavior are likewise not advertised without the required live proof through this package's public port.

The official SDK is isolated under src/freestyle/internal/; public configuration and capability metadata do not import vendor types. All create, lookup, exec, lifecycle, and deletion operations have explicit deadlines. See the Freestyle adapter notes for dependency provenance, provider constraints, and capability evidence.

Vercel Sandbox runtime contract

VercelSandboxRuntime takes an explicit token, team id, project id, ownership name prefix, and home directory, and passes credentials on every SDK call so the vendor's own environment and on-disk OAuth fallbacks can never supply an operator's identity.

A Vercel sandbox is addressed by its name, not an opaque id, so RuntimeHandle.id carries the name and the configured prefix is a real ownership boundary: stop, start, and delete refuse any name outside it. Sandbox.list supports server-side namePrefix and tags filters, so label lookup is a real search here — but every row is re-checked against the requested tags in process, because a server filter that were ever ignored would hand back someone else's sandbox as a warm lease. getById is implemented over list rather than Sandbox.get, which resumes and bills the sandbox as a side effect.

Deletion is verified by absence, and a row that reappears under the same name with a different createdAt counts as proof our sandbox is gone rather than as a survivor. Failed verification retains the registration so cleanup stays retryable. acquire() returns an AsyncDisposable for await using scopes.

Commands run as sh -c with cwd and env passed through provider fields, so no caller value is spliced into a command string. Every operation carries its own deadline, under an absolute retryDeadlineMs ceiling that spans the SDK's internal retries.

Warm leasing, lifecycle, PTY, snapshots, streaming logs, fork, and verified cleanup are all declared false pending live evidence; never-idle is settled false, because every Vercel sandbox carries a termination deadline. The official SDK is isolated under src/vercel/internal/. See the Vercel adapter notes for dependency provenance, capability evidence, and the active-CPU-versus-wall-clock pricing analysis.

Capabilities are also declared structurally. PTY and snapshots resolve to "not-exposed" — real in the SDK, unreachable through this port, and so not something a live probe may promote — while lifetime resolves to "deadline", the settled reason a Vercel sandbox has no never-idle tier. filesystem is deliberately left "unknown": durability is per-instance configuration (persistent), and surviving a stop/resume is the same round trip lifecycle is still awaiting live proof of.

Modal runtime contract

ModalRuntime takes an explicit Modal token pair (tokenId and tokenSecret — Modal does not use a single bearer key), an App name, an image tag, a home directory, and an ownership-name prefix. It never reads ambient credentials or a local Modal profile.

A Modal Sandbox is a child of an App, built from an Image, and it has a maximum lifetime after which the provider terminates it — the SDK's own default is five minutes. maxLifetimeMs is therefore required configuration and is always sent explicitly. createTimeoutSeconds on launch is a deadline on the create call and is deliberately not forwarded to that lifetime.

Modal exposes no stop/start for a Sandbox; terminate is the only lifecycle transition and it is terminal. start and stop are absent rather than no-ops, and lifecycle is declared false permanently. Async exec is likewise not implemented: Modal cannot re-resolve a running exec by id, so the startScript/getScriptStatus/getScriptLogs trio is omitted entirely instead of being half-supported.

Ownership rides on Modal's native server-side tags rather than on a naming convention. Every sandbox carries an ownership tag, every lookup filters on it server-side, and reattachment and deletion both re-check it. Warm leasing is implemented against that real tag filter but remains undeclared until a live probe confirms it. Snapshots, volumes, PTY, and tunnels exist in the provider and are documented, but are not advertised because this package's port exposes no operation for them.

That last distinction is now stated structurally rather than in prose. The adapter declares declaredCapabilityModes, so PTY and snapshots resolve to "not-exposed" — a fact about this package's port, which isPendingEvidence() reports as unmovable — rather than to a bare false a later canary might read as merely unverified. lifetime resolves to "deadline", which is the structural reason a Modal sandbox can never be never-idle. Output is "buffered": Modal streams, the adapter drains. Warm leasing deliberately gets no mode, because modes describe a capability's shape and not its verification state.

The official SDK is isolated under src/modal/internal/, and because that SDK speaks gRPC rather than HTTP there is no injectable transport seam; the boundary is a structural mirror that is checked at build time instead. All create, lookup, exec, upload, and deletion operations have explicit deadlines. See the Modal adapter notes for dependency provenance, provider constraints, cost model, and capability evidence.

Development

npm ci
npm run build      # tsc → dist/
npm run typecheck
npm test           # node:test

Requires Node.js 20 or newer. The microsandbox adapter's own tests need Node 22+ to load the real SDK; without it, its SDK-contract checks skip rather than fail.

Releasing

Publishing is manual-dispatch only, via the Publish workflow, and defaults to a dry run. Nothing publishes automatically on push or merge.

Authentication is npm OIDC trusted publishing — there is no publish token to store. A real publish requires the repository owner to register this repository and workflow as a trusted publisher for the package on npmjs.org; releases carry provenance attesting the commit and workflow they were built from.

License

Apache-2.0. See LICENSE.

About

Switch between sandboxes with ease

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages