Sandbox providers
The SandboxProvider facade over @pleach/sandbox's three endpoint-shape adapters, plus the SandboxComposite high-level tool surface coding agents reach for.
@pleach/coding-agent/sandbox ships a small facade — SandboxProvider
— that sits ABOVE the three endpoint-shape adapters in
@pleach/sandbox (httpStream / httpPoll / childProcess). The
adapters know how to talk to a vendor; the facade decides when to
acquire, when to reuse a pooled session, and what high-level value the
coding-agent loop holds across a turn.
The shape this page documents is the layer ABOVE the SandboxAdapter
contract in @pleach/core (see
Sandbox for the lower layer) — not a replacement.
What you get
Two factories. Both vendor-neutral; vendor coupling lives in the
underlying @pleach/sandbox adapter.
import {
createPooledSandboxProvider,
createSandboxComposite,
} from "@pleach/coding-agent/sandbox";| Factory | What it returns | When you reach for it |
|---|---|---|
createPooledSandboxProvider({ adapter, poolSize? }) | SandboxProvider with acquire() / release() | Once per runtime. The provider holds a bounded pool of warm sessions. |
createSandboxComposite({ session }) | SandboxComposite with install / upload / download / diff / env / gitClone / benchmark | Once per acquired session, inside the tool loop. |
The provider is the long-lived value. The composite is the per-session value. The coding-agent runtime holds the provider for its lifetime and constructs a composite each time the agent's loop enters a turn.
The three endpoint shapes (below)
The provider's adapter field is one of the three endpoint-shape
adapters from @pleach/sandbox:
| Adapter | Wire shape | Where vendors compose in |
|---|---|---|
createHttpStreamSandboxProvider | REST + text/event-stream for /execute/stream | Modal, Vercel Sandbox, E2B (streaming variant) |
createHttpPollSandboxProvider | REST; executeStream buffers and emits one terminal chunk | Daytona, Azure Container Instances |
createChildProcessSandboxProvider | child_process.spawn + node:fs/promises | Local dev, in-CI sandboxing |
Plus a first-class AWS Fargate provider (createFargateSandboxProvider)
that owns the ECS task lifecycle and delegates the in-container surface
to the canonical HTTP + SSE shape.
The adapter guide, packages/sandbox/ADAPTERS.md, is organized by
backend shape rather than by vendor: the HTTP wire shape, the AWS ECS
Fargate provider, and the local child-process provider. It shows the
auth callback and path overrides for each shape, and explains how to
front a vendor whose API doesn't match the wire protocol (run a small
gateway, or implement SandboxProvider directly). @pleach/sandbox
ships no per-vendor packages or configurations; vendor coupling lives
in your own gateway or adapter config.
Pooled provider
import { createPooledSandboxProvider } from "@pleach/coding-agent/sandbox";
import { createHttpStreamSandboxProvider } from "@pleach/sandbox";
const adapter = createHttpStreamSandboxProvider({
baseURL: process.env.SANDBOX_BASE_URL!,
auth: async () => ({
headers: { Authorization: `Bearer ${process.env.SANDBOX_TOKEN!}` },
}),
});
export const provider = createPooledSandboxProvider({
adapter,
poolSize: 1, // default — per-runtime singleton
});auth may return the wrapped form shown here or a flat header map
(async () => ({ Authorization: `Bearer ${token}` })); both send
the same request headers. A result counts as wrapped only when its
single key is headers holding an object. { headers: null }, or
returning null / undefined, means no auth headers. Every header
value must be a string, and anything else throws a TypeError that
names the header without echoing its value.
poolSize defaults to 1, which matches the chat-app baseline (one
session warm at a time). Raise it for parallel-verify swarms — when
each sub-agent in a swarm needs its own isolated workspace, set
poolSize: 4 (or however many sub-agents you fan out to).
Acquire and release
const session = await provider.acquire({
workspaceDir: "/workspace",
runtime: { language: "node" },
});
try {
await session.client.exec("npm install");
// ...do work...
} finally {
await provider.release(session);
}The session is a { id, client, runtime, handle } value. The
coding-agent tool factories (file tools)
consume session.client — the 3-method SandboxClient contract.
session.handle is the underlying @pleach/sandbox handle, opaque
for the typical case; reach for it only when you need the wider
8-method @pleach/sandbox SandboxProvider contract
(listFiles / isAlive / capabilities / executeStream).
Idempotency on sessionId
const a = await provider.acquire({ sessionId: "chat-42" });
const b = await provider.acquire({ sessionId: "chat-42" });
// a === b — pool short-circuits on the caller-supplied idWhen the caller supplies a sessionId that's already pooled, the
provider returns the existing session whether idle or in-use. This is
the load-bearing primitive for re-attaching to a session across a
restart — pair it with a persistent chatId → sandboxSessionId
mapping at the host layer.
The composite tool surface
SandboxComposite is the layer the coding-agent loop holds for the
session's lifetime. Seven composite ops, each a thin shell over
session.client.exec + readFile + writeFile.
import { createSandboxComposite } from "@pleach/coding-agent/sandbox";
const composite = createSandboxComposite({ session });| Op | Returns | Notes |
|---|---|---|
install(packages, opts?) | SandboxExecResult | Probes for lockfile (pnpm / yarn / bun / npm / poetry / uv / pip) when manager: "auto"; falls back to npm. |
upload(local, remote, opts?) | void | Reads local via node:fs/promises (host) and writes via session.client.writeFile. |
download(remote, local, opts?) | void | Symmetric — reads from sandbox, writes to host. |
diff(pathA, pathB, opts?) | { unifiedDiff, differs } | Shells out to diff -u; differs is true when exit code is 1. |
env.get(name) / env.set(name, value) / env.list() | string | undefined / string / Record<string, string> | set writes to .pleach-env; consumer sources it before subsequent execs. |
gitClone(url, opts?) | SandboxExecResult | Defaults: --depth 1, destination = repo basename. |
benchmark(command, opts?) | BenchmarkResult | N iterations (default 3); returns durations, mean, min, max, per-iteration exit codes. |
install — package-manager probe
The auto mode runs one probe exec, looking for lockfiles in order:
pnpm-lock.yaml, yarn.lock, bun.lockb, package-lock.json,
poetry.lock, uv.lock, requirements.txt. First hit wins; no
match falls through to npm. Pin a manager to skip the probe:
await composite.install(["zod", "valibot"], {
manager: "pnpm",
dev: true,
});env.set and the .pleach-env file
The composite does NOT mutate session-wide env in-band — most sandbox
transports don't expose a "persist env" wire op. Instead, env.set
appends to a .pleach-env file at the workspace root; subsequent
execs source it explicitly:
await composite.env.set("FOO", "bar");
await session.client.exec("set -a; . .pleach-env; set +a && my-tool");env.list runs printenv against the live session — its result
reflects what the sandbox sees right now, not what .pleach-env
contains. Treat the two as orthogonal.
benchmark for cost-aware loops
const result = await composite.benchmark("pnpm vitest run", {
iterations: 5,
timeoutMs: 60_000,
});
console.log(result.meanMs, result.minMs, result.maxMs);
console.log(result.exitCodes); // any non-zero indicates a failed iterationThe composite does NOT warm up the workspace between iterations — callers handle that out of band. Pair with eval and replay to capture per-iteration durations into the audit ledger.
Wiring into a coding-agent runtime
import { createCodingAgentRuntime } from "@pleach/coding-agent/runtime";
import { createPooledSandboxProvider } from "@pleach/coding-agent/sandbox";
import { createHttpStreamSandboxProvider } from "@pleach/sandbox";
const adapter = createHttpStreamSandboxProvider({
baseURL: process.env.SANDBOX_BASE_URL!,
auth: async () => ({
headers: { Authorization: `Bearer ${process.env.SANDBOX_TOKEN!}` },
}),
});
const provider = createPooledSandboxProvider({ adapter, poolSize: 1 });
const runtime = createCodingAgentRuntime({
sandboxProvider: provider,
// ...rest of SessionRuntimeConfig
});
await runtime.start({ sessionLabel: "chat-42" });The runtime owns the provider's lifetime — runtime.stop() invokes the
provider's dispose / destroy / shutdown if present (duck-type
dispatch).
What's vendor-neutral, what isn't
The provider, the composite, and the tool factories are vendor-neutral
by construction. The only vendor-coupling point is the adapter you
pass to createPooledSandboxProvider. Swap the adapter — keep
everything else.
The composite does NOT branch on session.handle.provider. Vendor
optimizations (a vendor's bulk-upload endpoint, a vendor's faster
exec shape) belong on the underlying adapter, not in the composite.
This keeps the surface stable as vendors come and go.
Where to go next
Compliance · GDPR right-to-erasure
executeErasureRequest — the structural primitive for Article 17 erasure against an append-only hash chain. Reads the fundamental tension honestly; surfaces both approaches.
File tools
The eight vendor-neutral file / diff / exec tool factories — read_file, write_file, apply_diff, search_files, list_files, run_tests, git_clone, git_diff.