Agent Relay Sandbox Router
Credential-first SDK and routing substrate for sandbox workloads.
It constructs and selects among configured provider pools while delegating every runtime
operation to @agent-relay/sandbox.
This repository does not fork, copy, or implement provider runtime adapters.
V1 boundary
The router owns:
- provider-declared sandbox capability profiles, including account-scoped maximum lifetime, and positive/negative requirements;
- a stable provider registry interface;
- requirements filtering before ranking;
- deterministic
cost,latency,reliability, andbalancedranking; - bounded acquisition-time fallback for explicitly classified, proven pre-allocation failures (three attempts by default, ten maximum);
- read-only account capacity measurement and explicitly ordered capacity spillover;
- stable
sbx_…locator ids, labels, and a durable provider-mapping seam; - private process-manifest validation and deterministic startup/teardown plans;
- typed, sanitized routing errors and structured observability events;
- best-effort cleanup when persistence fails after a sandbox was acquired.
Credentials arrive only as explicit arguments and remain in runtime closures. The router does not read provider keys from environment variables or files. It does not own provider SDK adapters, secret storage, billing, metering, REST handlers, database infrastructure, or cloud policy. The in-memory registry and mapping store are test/local references, not production storage.
The process-manifest scaffold is also intentionally private composition logic.
It makes useful-node readiness, history-safe teardown, and evidence-gated idle
policy explicit without changing the public runtime port. See
docs/process-manifest-design.md.
The credential-first entrypoint supports Daytona, E2B, Vercel, Freestyle,
Agent37, and hosted Microsandbox through @agent-relay/sandbox >=0.1.10.
Cloud already consumes the routing package; its environment/secret lookup,
snapshot selection, logger, safety configuration, and signed Microsandbox
bridge remain caller-owned.
This is a provider-pool ranker for the cloud acquisition boundary. It is not a
fleet/node scheduler. Requests selecting fleet, a concrete node affinity, or
no sandbox are refused with RoutingNotApplicableError, leaving Factory's
existing fleet placement authority and consumer-local execution untouched.
Control flow
SandboxRequest
│
├─ validate request and attempt bound
├─ filter provider-declared correctness/trust requirements
├─ resolve request-scoped operational signals for survivors
├─ filter availability and time-to-ready budget
├─ deterministically rank eligible pools
└─ attempt at most N providers
│
├─ SandboxRuntime.launch(...)
├─ allocate sbx_<32 hex>
├─ persist sbx id ↔ labels ↔ provider id ↔ provider sandbox id
└─ return only the Agent Relay id
Provider registration order never breaks a scoring tie; stable provider id lexicographic order does. Balanced ranking normalizes the current eligible set and applies 35% cost, 25% selected latency objective, and 40% completion-within-budget reliability. Capabilities are never probed and are never part of a "more is better" score: registrations declare them, requests may require or forbid them, and the router trusts those declarations.
Credential-first construction
Install @agent-relay/sandbox-router and @agent-relay/sandbox@^0.1.10, plus
only the SDKs for providers you use. Daytona uses @daytonaio/sdk in
>=0.180.0 <0.206.0; E2B uses e2b in >=2.35.0 <3.0.0. The sandbox package
lists the optional SDK requirements for Vercel, Freestyle, and Microsandbox.
Agent37 uses its HTTP adapter.
Provider credential readiness
Trusted backends can supply provisioning: { hasAvailableCredentials(providerId) }
to either SandboxRouter or createSandboxRouter. This tenant/environment-scoped
port reads existing credential readiness and returns only a boolean. It never
creates accounts or returns credentials. Account setup must complete before
acquisition; omitting the port preserves existing behavior.
Both provision() and provisionDetached() exclude an unavailable binding before
resolving operational signals, ranking or reading capacity. A thrown error or a
non-boolean result aborts acquisition with a sanitized ProvisioningAvailabilityError.
Ready credentials do not imply available capacity: enabled capacity routing still
refuses unknown or exhausted capacity. Readiness gates new allocation; it does
not prevent cleanup of an already acquired sandbox.
Capacity measurement and spillover
Each builtin registration has a CapacityReader.getCapacity() method. Call
await router.getCapacity() to read all configured accounts without creating
or starting a sandbox. Results are either { status: "known", resources } or
{ status: "unknown", reason }. Each resource reports used, limit, unit,
headroom, and the configured pool's allocation per launch (required). CPU
uses vCPU, memory/disk use MiB, and instance quotas use sandbox slots.
Enable admission checks with an explicit ordered allowlist:
const router = createSandboxRouter({
providers: {
daytona: {
apiKey: daytonaKey,
snapshot: snapshotName,
capacity: {
limits: { cpu: 250 }, // This organization's verified cap, not a universal default.
demand: { cpu: 2 }, // Actual allocation of the configured snapshot.
},
},
microsandbox: {
apiKey: microboxKey, image: microboxImage, homeDir: "/root",
capacity: { limits: { sandboxes: verifiedMicroboxSlotLimit } },
},
},
capacity: { providerOrder: ["daytona", "microsandbox"], timeoutMs: 5000 },
});
const measurements = await router.getCapacity(); // Only reads inventory.
The router applies capabilities, resource requirements, exact providerId, and
operational eligibility first, then uses providerOrder in place of strategy
ranking. Unlisted providers cannot be selected. Before each launch it checks
fresh capacity against the greater of the configured allocation and request
requirements. Every reported dimension must fit. Full, unknown, malformed,
failed, or timed-out readers are skipped, without consuming a launch attempt.
If none has room, ProviderCapacityExhaustedError carries code
PROVIDER_CAPACITY_EXHAUSTED and sanitized UNKNOWN/EXHAUSTED rejections.
The same behavior applies to provisionDetached. An exact provider constraint
never spills outside that provider. acquisitionFallback: "forbidden" limits
actual launches to one; capacity skips perform no acquisition.
| Provider | Builtin measurement | Required caller limit |
|---|---|---|
| Daytona | Complete organization inventory, summed across all states/workspaces | CPU and/or memory/disk, with matching per-launch demand; memory/disk inputs are GiB |
| E2B | Paginated running sandbox inventory for the supplied API key | Running sandbox concurrency |
| Microsandbox / Microbox | Paginated inventory across all states, using the supplied hosted backend | Total account sandbox slots |
| Agent37 | Account instance inventory across all states | Total account instance slots |
| Vercel | Explicit unknown: NOT_SUPPORTED; the adapter list is prefix-scoped | Supply a custom account-wide reader |
| Freestyle | Explicit unknown: NOT_SUPPORTED; the generic count is a placeholder | Supply a custom account-wide reader |
Inventory alone does not reveal an account quota. Missing verified limits
produce unknown: LIMIT_NOT_CONFIGURED, never zero or an inferred pricing-tier
cap. Daytona also returns unknown when snapshot demand is missing. Its
conservative all-state inventory sum preserves the cloud helper's semantics;
it may overestimate currently reserved compute. Only configured quota dimensions
are measured, so configure every limit you need to enforce. Capacity is a
point-in-time observation, not a reservation: concurrent callers can race, and
provider create failures retain the existing outcome-aware fallback policy.
Reads have a five-second default deadline; caller readers should cancel their
own underlying I/O when needed. No cache or credential state is shared across
router instances.
The constructor accepts a custom { getCapacity: async () => ... } in a
provider's capacity field for quota APIs or deployments outside these builtin
surfaces. A custom reader must report account-wide usage and actual launch
demand. For direct registry construction, attach it as ProviderDefinition.capacity.
Existing callers that omit the router-level capacity policy keep their
existing strategy-based routing behavior; measurement is still available.
summarizeDaytonaCapacity and parseDaytonaCapacityLimits are ported from
cloud's helper.
The display summary retains warning/critical thresholds, organization totals,
workspace-scoped idle details, limit provenance, and provisioningCheck: "not_covered". Admission uses strict measurement validation rather than the
display helper's numeric coercion. See also the
Daytona list API
and E2B listing contract.
Run node scripts/verify-capacity-mutations.mjs to prove the spillover and
per-provider assertions reject deliberate regressions. The runner uses fake
credentials and local fixtures, restores each mutation, and requires the
restored tests to pass.
Basic construction
import { createSandboxRouter } from "@agent-relay/sandbox-router";
const router = createSandboxRouter({
providers: {
daytona: {
apiKey: daytonaKey,
createParams: { autoStopInterval: 5, autoDeleteInterval: 0 },
},
e2b: { apiKey: e2bKey },
},
});
const sandbox = await router.provision({
launch: { labels: { requestedBy: "my-application" } },
});
try {
const result = await router.runScript(sandbox.id, { command: "echo hello" });
console.log(result.output);
} finally {
await router.destroy(sandbox.id);
}
createSandboxRouter is synchronous; provider adapters and optional SDKs load
on first operation. The returned router extends SandboxRouter, with
runScript(id, options) and destroy(id) resolving the provider through the
stored mapping. destroy tolerates an already-deleted provider sandbox.
createProviderRuntime(provider, options) exposes the same credential-safe
runtime construction for callers that maintain their own registry.
| Provider | Required explicit configuration | Convenience defaults |
|---|---|---|
| Daytona | apiKey | API https://app.daytona.io/api, target us, home /home/daytona |
| E2B | apiKey | Template base |
| Vercel | token, teamId, projectId, defaultHomeDir, namePrefix | Adapter defaults |
| Freestyle | apiKey, defaultHomeDir, namePrefix, persistence | Adapter defaults |
| Agent37 | apiKey, baseUrl, defaultHomeDir | Adapter defaults |
| Microsandbox | apiKey, homeDir, image | Hosted backend; optional url |
Override Daytona's home directory when selecting a custom snapshot. E2B's
base template is for basic workloads; a custom template must be supplied for
application-specific binaries. Microsandbox profile files and local execution
and host-local snapshots are excluded from this credential-first surface.
Provider entries also accept capabilities and signals. Without explicit
metadata, the factory advertises only synchronous execution and file transfer;
resource limits are zero and region/image/isolation/durability claims are empty.
Thus requests requiring those properties are refused until you declare the
configured pool's envelope. Default signals are equal ranking placeholders,
not measured price, latency, reliability, or live health. Supply signals or
resolveRoutingSignals to make informed multi-provider selections.
Mappings default to memory. Supply mappings for durable, cross-process
identity. Provider keys never enter the mapping, observability events, or the
serializable runtime facade. Provider SDK failures, including construction,
lookup, execution and destruction, become ProviderRuntimeError with only
provider and operation; original messages, stacks and causes are discarded.
A failure classifier on this surface receives that sanitized error, so it must
not infer a safe retry from missing provider details. Acquisition failures stay
outcome-unknown and terminal by default.
The original new SandboxRouter({ registry, mappings, ... }) and
routeProviders(request, providers) signatures remain unchanged. The explicit
runtime path still supports caller-owned adapters and provider error classifiers.
Registration and provisioning
import {
InMemoryProviderRegistry,
SandboxRouter,
asProviderId,
} from "@agent-relay/sandbox-router";
const registry = new InMemoryProviderRegistry([
{
id: asProviderId("primary-eu"),
runtime: externallyConstructedRuntime,
capabilities: externallyDeclaredCapabilities,
signals: {
estimatedCost: 0.12,
coldStartLatencyMs: 850,
readyLatencyMs: 12_000,
completionWithinBudgetRate: 0.999,
available: true,
},
},
]);
const router = new SandboxRouter({
registry,
mappings: durableMappingStore,
observer: structuredEventSink,
classifyProviderFailure: (error, provider) => {
if (isProvenPreAllocationCapacityFailure(error, provider)) {
return { code: "PROVIDER_CAPACITY_PRE_ALLOCATION", retryable: true };
}
return { code: "PROVIDER_LAUNCH_OUTCOME_UNKNOWN", retryable: false };
},
});
const sandbox = await router.provision({
resources: { cpu: 4, memoryMb: 8192 },
requires: {
requiredCapabilities: ["pty", "publicPorts"],
forbiddenCapabilities: ["nestedUserNamespaces"],
image: { identity: "agent-v4", requiredBinaries: ["node"] },
timeout: {
semantics: ["command-lifetime"],
maxReadyLatencyMs: 30_000,
requiredSandboxLifetimeMs: 30 * 60_000,
completionBudgetMs: 1_800_000,
},
},
region: { preferred: ["eu-west"], required: true },
routing: {
strategy: "balanced",
latencyObjective: "ready",
acquisitionFallback: "allowed",
maxAcquisitionAttempts: 3,
},
});
Runtime construction is intentionally external. A configured runtime may hold its provider authorization internally, but credentials never enter a provider definition, request, routing event, mapping, or router error.
Maximum sandbox lifetime is a correctness constraint, not a ranking signal.
Each registered pool may declare capabilities.timeout.maxSandboxLifetimeMs
for the provider account behind that runtime. A finite value is an inclusive
ceiling; null explicitly means unbounded; omission means the account limit is
unknown. When a request supplies
requires.timeout.requiredSandboxLifetimeMs, unknown and insufficient pools
are rejected with SANDBOX_LIFETIME before ranking or acquisition. Requests
without a lifetime requirement remain backward compatible with registrations
that omit the declaration.
The classifier in this example represents caller-owned provider knowledge;
isProvenPreAllocationCapacityFailure must only return true when the provider
guarantees that allocation did not occur. Omitting the classifier is safer: all
launch failures are terminal by default.
Routing-signal seam
ProviderRoutingSignals are comparable selection inputs, not charges or usage
records. estimatedCost means the expected cost of the relevant acquisition,
while coldStartLatencyMs and readyLatencyMs deliberately separate SDK
response from actual workload readiness. completionWithinBudgetRate counts
hangs as failures instead of treating only returned errors as unreliable. A caller can supply
resolveRoutingSignals(provider, request) to incorporate live health or its own
workload knowledge—for example, cold acquire plus destroy multiplied by an
expected step count. That resolver stays outside billing and metering.
Placement, fallback, and identity guardrails
- Fallback exists only inside
provision, before any exec, lease, or stateful operation. The credential-first execution path resolves the original provider mapping and never reroutes resident work. Leased/resident callers setacquisitionFallback: "forbidden". acquisitionFallback: "allowed"and an attempt limit do not make a launch error retryable. Advancing to another provider additionally requires an explicit caller classifier to returnretryable: truefor a narrowly proven pre-allocation failure, such as provider capacity rejection before creation.- Provider capability is declared, not inferred from method presence. The vocabulary includes image/binary identity, egress class, timeout semantics and granularity, provider-account lifetime, state durability, ports/tailnet, long-running process/PID/PTY/restart needs, unattended enrollment, and required restrictions.
sbx_…is a provider-independent locator, not workload identity. It coexists with labels. Durable state-directory identity, FactoryinvocationId, and PID tuples remain owned by their existing lifecycle systems and are not replaced.placement.authority: "none" | "fleet"andnodeAffinityexplicitly bypass provider routing; this library never becomes a second fleet scheduler.
Failure semantics
- Requirements or availability failures never call a provider.
- Generic or unclassified launch failures are outcome-unknown and terminal by default because a provider may have allocated remotely before throwing.
- Only a caller-supplied classifier can authorize fallback by returning
retryable: true; callers must reserve it for narrowly proven pre-allocation failures. Retryable failures advance only until the acquisition attempt bound. - Classifiers expose only sanitized codes; terminal failures raise
ProviderProvisioningErrorand emit an attempt-failed event withretryable: false. - Raw provider error messages are not emitted or copied into exhaustion data.
- Mapping-store failure after acquisition is not a provider failure: the router attempts to destroy that sandbox, emits whether cleanup succeeded, and stops.
- Observer failures never affect selection, acquisition fallback, or provisioning.
Public-base gaps (not copied here)
These belong in @agent-relay/sandbox or its provider adapters if required:
- Portable launch requirements:
SandboxRuntime.launchaccepts only name/env/labels/timeout, not resources, region, or isolation. V1 therefore treats each registration as a preconfigured provider pool and filters using its declared envelope; it does not inject unsupported fields. Passing portable requirements into provider creation needs a public-port decision. - Partial-allocation cleanup:
SandboxRuntime.launchreturns a handle only on success. If an adapter allocates remotely and then throws, the router has no cleanup handle. Leak-safe recovery for that case must be guaranteed by the adapter or added to the public port. The router therefore treats an unclassified launch exception as terminal rather than risking a duplicate allocation through automatic fallback. - Portable declared capability vocabulary: the public outer runtime descriptor covers a few operational methods, not image identity, binaries, egress, timeout semantics, provider-account lifetime, state durability, trust restrictions, regions, resources, network, or GPU envelopes. The commercial registry owns these provider declarations for now. They must not be implemented through probes; present-but-no-op methods have already proved that inference unsafe.
- Optional peer declaration surface: the public package declarations
reference Daytona peer types even for an adapter-free type-only consumer.
This package uses
skipLibCheck; the public base should isolate optional provider declaration imports.
Development
Requires Node.js 20 or newer.
npm install
npm run build
npm run typecheck
npm test
Behavioral tests are named in must-fire/must-not-fire pairs so both the trigger and its guard are visible. The package is private and has no publish script.
The credential-first regression proof can be reproduced with
node scripts/verify-tenant0-mutations.mjs. It temporarily mutates and restores
source, asserts each negative control fails, then runs the clean regression suite.
Run it without concurrent source edits.
The opt-in live proof is npm run build followed by
node scripts/prove-tenant0-daytona.mjs. It reads Daytona credentials from
~/Projects/AgentWorkforce/cloud/.env at point of use, provisions a bounded
sandbox, verifies command stdout, destroys it, and audits its unique labels.
It writes only a sanitized transcript under .workflow-artifacts/tenant0-0906/.