@pai/sandbox
Shared filesystem tools and a provider contract for local or isolated remote execution.
@pai/sandbox gives agents a shared toolset for running commands and editing files
through a configured execution provider. Providers determine isolation: a trusted
local provider runs with the host user's permissions, while remote providers can
supply isolated machines. The package ships the sandbox
capability, the per-tool-call Sandbox facade, and the
SandboxProvider contract implementations build on.
Implementations decide where the sandbox runs:
@pai/sandbox-local— trusted local filesystem and explicitly enabled host commands; no OS isolation.@pai/sandbox-e2b— E2B Firecracker microVMs, for production.@pai/gke-sandbox-sdk— standalone backend SDK for self-hosted GKE Agent Sandboxes.@pai/sandbox-gke— adapts the standalone GKE SDK to PAI agents.@pai/sandbox-docker— a container per sandbox with files on the host, for local development.@pai/sandbox-local— a trusted local directory and the host shell, for local coding agents.@pai/sandbox-inmemory— scriptable in-process fake, for tests and demos.
To run sandboxes yourself, self-hosting the sandbox cluster explains the cluster prerequisites and the runtime-only Terraform module that installs templates and application RBAC.
Quick start
Spread sandboxTools() into an agent's tools map and bind the provider at the runtime. The tools declare the sandbox capability, so omitting the binding is a compile error — and the agent module never mentions a provider, session keys, or cleanup:
import { createAgentRuntime, defineAgent } from "@pai/core";
import { sandboxCapability, sandboxTools } from "@pai/sandbox";
import { createE2BSandboxProvider } from "@pai/sandbox-e2b";
const agent = defineAgent({
name: "analyst",
model,
instructions: "Use the sandbox tools for calculations and file work.",
tools: { ...sandboxTools() },
});
const runtime = createAgentRuntime({
agent,
storage,
scopeKey: (identity) => identity.workspaceId,
capabilities: {
sandbox: sandboxCapability({ provider: createE2BSandboxProvider() }),
},
});This registers seven tools — bash, read, write, edit, grep, glob, and
listDirectory — that share one sandbox per conversation by default. The sandbox
is lazily created on first use, reused with keepalive, rediscovered by metadata
after a process restart, and recreated when reclaimed. With the default thread
scope, deleting a thread kills its sandbox. Workspace-scoped services can instead
share one environment across related conversations and delegated agents; their
application owner controls workspace destruction and resource cleanup.
Pass include or exclude (mutually exclusive) to register a subset, with the returned type narrowed to match:
sandboxTools({ include: ["bash", "read"] }); // { bash, read }
sandboxTools({ exclude: ["edit"] }); // everything but editDescriptions remain valid when tools are filtered and do not claim provider isolation. The host should supply the actual execution policy and workspace path in its agent instructions.
User-facing workspace browsers and reset controls should be PAI
commands. Commands receive the same scoped facade at
ctx.capabilities.sandbox as tools. For pipeline hooks that must run outside an
operation, such as attachment staging before the model loop,
createSandboxService(...) exposes the same facade and a binding backed by one
shared session manager.
Image inspection
The standard read tool handles UTF-8 text and PNG/JPEG images. Register it with
sandboxTools() as usual; no additional tool or configuration is needed. Image
reads require a model and provider that support native image content in tool
results. Text reads keep their existing numbered content and pagination.
import { defineAgent } from "@pai/core";
import { sandboxTools } from "@pai/sandbox";
const agent = defineAgent({
name: "visual-analyst",
model,
instructions: "Use read to inspect files, including rendered images.",
tools: sandboxTools(),
});Bind the same sandbox capability as in the quick start. The tool accepts an
absolute path and recognizes supported images by their bytes, independent of
the filename. The offset and limit inputs apply to text; an image read returns
the whole image. Images must be at most 2 MiB; resize larger images with the
application's tooling before reading them. Other files retain the existing
text/binary detection; this does not add PDF or Office document extraction.
Render document pages to PNG/JPEG with the application's tools, then read those
images for visual checks.
Image results contain status: "image", path, mediaType, base64 data,
and environmentReset. The SDK's toModelOutput projection sends the image,
its path and any reset notice to the model. The original JSON snapshot remains
in history, so replay does not depend on a sandbox that may have expired.
Text and other results retain their existing JSON representation when sent to
the model. Base64 and repeated image context have storage and model costs; the
per-image limit does not bound the total conversation.
An oversized image returns binary with a resize message and no encoded image
data. Missing paths, directories and invalid inputs retain the existing read
results. Cancellation and infrastructure errors retain ordinary sandbox failure
behavior.
The 2 MiB limit bounds image output and persistence. The provider's existing file-read limits still govern transfer: its whole-file read happens before the tool identifies the format. Large text files keep their existing pagination behavior. Signature recognition does not decode or fully validate an image; the model provider performs decoding. Filesystem access and path containment remain the sandbox provider's responsibility.
Custom tools
Custom tools declare the same capability and consume the Sandbox facade at ctx.capabilities.sandbox:
import { defineTool } from "@pai/core";
import { sandbox } from "@pai/sandbox";
import { z } from "zod";
const workspaceSummary = defineTool({
id: "workspaceSummary",
description: "Summarize the sandbox working directory.",
inputSchema: z.object({ path: z.string().default("/home/user") }),
outputSchema: z.object({
entries: z.array(z.string()),
listing: z.string(),
environmentReset: z.boolean(),
}),
capabilities: { sandbox },
execute: async (ctx) => {
const ws = ctx.capabilities.sandbox;
const { listing, entries } = await ws.use(async (session) => ({
listing: (await session.runCommand({ command: `ls -la ${ctx.input.path}` })).stdout,
entries: (await session.listDir({ path: ctx.input.path })) ?? [],
}));
return {
entries: entries.map((entry) => entry.name),
listing,
environmentReset: ws.environmentReset,
};
},
});Include environmentReset: z.boolean() in the output schema and return ws.environmentReset — it is the model-facing channel for sandbox resets.
Sandbox facade
The object tools receive is neither the raw session nor a bare handle — it is a per-tool-call facade with the retry and reset policy built in:
type Sandbox = {
/** Opaque lifecycle key derived by core under the binding's scope. */
readonly key: string;
/**
* True once any operation in THIS tool call ran in a recreated environment
* (or after `reset()`). Surface it in tool output so the model learns
* earlier files and processes are gone. Latches: it never flips back to
* false within one tool call.
*/
readonly environmentReset: boolean;
runCommand(input: SandboxRunCommandInput): Promise<SandboxCommandResult>;
readFile(input: SandboxReadFileInput): Promise<Uint8Array | null>;
writeFile(input: SandboxWriteFileInput): Promise<void>;
listDir(input: SandboxListDirInput): Promise<SandboxDirEntry[] | null>;
/**
* Multi-op block against ONE session; the whole block retries once on
* mid-block reclamation, so a sequence never straddles two environments.
*/
use<T>(operation: (session: SandboxSession) => Promise<T>): Promise<T>;
/**
* Run a multi-operation mutation on one session, serialized with writes and
* other exclusive operations for the same sandbox key.
*/
useExclusive<T>(
operation: (session: SandboxSession) => Promise<T>,
): Promise<T>;
/** Destroy the environment now; the next operation provisions fresh. */
reset(): Promise<void>;
};
/** Create a lazy facade from a session handle. */
function createSandbox(handle: SandboxSessionHandle): Sandbox;Every operation acquires the live session (lazy create / reconnect / keepalive) and retries once when the sandbox was reclaimed mid-call, so tool bodies never manage sandbox lifecycles. Use use(...) when several operations must observe one consistent environment. Use useExclusive(...) for read-modify-write sequences that must serialize with other mutations. createSandbox is cheap — the provider is not touched until the first operation — which also makes it a convenient real facade for tool tests.
sandboxCapability
sandboxCapability(options) builds the CapabilityBinding for the sandbox token:
type SandboxCapabilityOptions = {
/**
* Instance keying policy. Defaults to `"thread"` — one sandbox per
* conversation. Scope is deployment policy, not agent semantics: the same
* agent can run per-thread sandboxes in production and one shared box in a
* demo by changing this option.
*/
scope?: CapabilityScope;
provider: SandboxProvider;
/**
* Options for the sandboxes the binding creates. The manager owns
* `bindingKey`: it is always the instance key.
*/
createOptions?:
| SandboxSessionCreateOptions
| ((input: { key: string }) => SandboxSessionCreateOptions);
keepAliveTtlMs?: number;
sessionKeyMetadataKey?: string;
};
type SandboxSessionCreateOptions = Omit<CreateSandboxInput, "bindingKey">;
function sandboxCapability(
options: SandboxCapabilityOptions,
): CapabilityBinding<Sandbox>;The binding creates and owns its session manager. Use createSandboxService()
when application pipeline code also needs access to the same scoped sandboxes.
Binding semantics:
acquireis cheap — a lazy per-tool-callSandboxfacade that only touches the provider on first use.- thread-deletion
disposekills every sandbox for the instance key and evicts its manager state, including orphans rediscovered by metadata. close(runtime shutdown) releases in-process state and callsprovider.close?.()without killing sandboxes — they survive restarts via metadata rediscovery.
Sandboxes must never be shared across trust contexts. The default "thread" scope guarantees that by construction; if you pass a custom scope, bind instances to the narrowest trust context available.
createSandboxService
type SandboxService = {
readonly binding: CapabilityBinding<Sandbox>;
probe(): Promise<void>;
acquire(ref: CapabilityKeySource): Sandbox;
reset(ref: CapabilityKeySource): Promise<void>;
close(): Promise<void>;
};
function createSandboxService(
options: SandboxCapabilityOptions,
): SandboxService;Use the service for trusted pipeline work that cannot run as a command. Register
service.binding with the runtime; service.acquire(ref) then returns the same
lazy, scoped facade agent operations receive. reset(ref) sweeps the current
environment without provisioning one when the scope has never been used.
probe() asks the provider whether its backend can serve this deployment
without provisioning anything, which is what a status route wants:
app.get("/sandbox/status", async (c) => {
try {
await sandboxes.probe();
return c.json({ enabled: true, error: null });
} catch (error) {
return c.json({
enabled: false,
error: isSandboxUnavailableError(error) ? error.reason : "unknown",
});
}
});const sandboxes = createSandboxService({ provider, scope: "thread" });
const runtime = createAgentRuntime({
agent,
scopeKey: (identity) => identity.workspaceId,
capabilities: { sandbox: sandboxes.binding },
});
const workspace = sandboxes.acquire({ scopeKey, threadId });Tool outputs
Every tool output includes environmentReset: boolean. When the provider reclaimed the sandbox between calls (idle expiry, restart), the tool transparently recreates it, retries once, and reports environmentReset: true so the model knows files and processes from earlier calls are gone. A reset is tool output the model can react to, never a crash.
Expected conditions — a missing file, a non-unique edit target, a bad regex — use
structured output. Tools with distinct failure shapes return a status union;
invalid operations include corrective diagnostics. Unexpected provider failures
still propagate as tool errors.
| Tool | Input | Output |
|---|---|---|
bash | { command, cwd?, timeoutMs? } | { stdout, stderr, exitCode, timedOut, truncated, environmentReset } |
read | { path, offset?, limit? } | status: "ok" (content with "N: " line numbers, totalLines, offset, linesShown, truncated, nextOffset) | "not_found" (didYouMean) | "binary" (byteLength) | "invalid" |
write | { path, content } | status: "ok" (byteLength, existed) | "invalid" |
edit | { path, oldString, newString, replaceAll? } | status: "ok" (replacements, snippet) | "not_found" (didYouMean) | "no_match" | "not_unique" (occurrences) | "invalid" |
grep | { pattern, path?, glob?, ignoreCase?, literal?, maxMatches? } | status: "ok" (matches[{ path, line, text }], matchCount, truncated, searchedPath) | "error" |
glob | { pattern, path?, limit? } | status: "ok" (paths, count, truncated, searchedPath) | "error" |
listDirectory | { path, offset?, limit? } | { status: "ok" | "not_found", path, entries, totalEntries, nextOffset, environmentReset } |
listDirectory uses the file API without executing a shell. Supply the absolute
directory path in the configured filesystem; providers do not share a universal
workspace root. It sorts entries by name and returns 100 entries by default
(maximum 200). Continue with offset: nextOffset until nextOffset is null. A missing
directory returns not_found and an empty page. The standalone definition is
sandboxListDirectoryTool, with ID pai.sandbox.list-directory. Paging bounds
the returned entries; the provider's current listDir contract still enumerates
the complete directory and does not accept cancellation or pagination inputs.
bash semantics:
- A non-zero exit code is a normal result, not a tool failure.
- Commands time out after 2 minutes by default; pass
timeoutMsfor slower commands. A command that exceeds the limit returns{ timedOut: true, exitCode: null }with any partial output instead of crashing the tool. - Output streams live: stdout and stderr are written to the tool's
stdoutandstderrdata channels with stable ids, so clients can render command output as it is produced. Interim writes are transient (live subscribers only) and carry a rolling tail capped at the output budget; the final truncated output is persisted once per channel — the same string the model sees. - Output beyond the budget is truncated to its tail (
truncated: true) and the full text is spooled to a file in the sandbox (/tmp/.pai/bash-*.log); the truncation banner names the file so the model cangreporreadit.
read paginates losslessly: content is returned with 1-based "N: " line-number prefixes, long lines are clipped, and a truncated read names its nextOffset so the model continues where it stopped. Missing files come back as status: "not_found" with near-miss didYouMean paths from the same directory; files with NUL bytes in their head come back as status: "binary".
write creates or replaces a whole file (creating missing parent directories) and reports existed: true when it replaced an existing one. Read-before-write is guidance in the tool description, not enforced — tools are stateless per call, so there is no durable per-thread store to hold read stamps, and the bundle does not pretend otherwise with a fake check. edit's exact-match requirement is the real staleness guard: a file changed since it was read no longer matches.
edit is exact-match replacement, deliberately not fuzzy: in a remote sandbox with no undo, a silent fuzzy rewrite is worse than one retry round-trip. oldString must match the file verbatim; the one deterministic concession is line-ending normalization (an LF oldString matches a CRLF-dominant file and vice versa, preserving the file's endings). Failures are precise: no_match says the text was not found, not_unique carries the occurrence count and suggests widening the match or replaceAll: true. A successful edit returns a "N: "-numbered post-edit snippet around the first replacement. Edits and writes through the same sandbox service serialize through useExclusive. Shell commands and separate service owners do not share that mutation lock; scope sharing does not isolate concurrent coding tasks.
grep and glob pick their engine inside the sandbox with a single compound command — ripgrep when installed, POSIX grep -rEn / find otherwise — so there is no probing round trip and no host-side state to go stale across environment resets. Both skip .git and search hidden files; ripgrep additionally skips gitignored files and binary detection is -I/rg-native. Globs — glob's pattern and grep's filter — match at any directory depth under the searched path on every engine (ripgrep matches --glob against the full printed path, so anchored slash-bearing patterns would silently match nothing under an absolute search root). Fallback limitations: grep --include only handles basename globs (path globs like src/**/*.py are rg-only), find approximates ** — a literal segment right after **/ can over-match on name prefixes — and brace expansion {a,b} is rg-only. glob returns paths in lexicographic order — a deliberate deviation from mtime ordering, which the session primitives cannot provide portably; deterministic ordering is also testable. An invalid regex is not a crash: the engine's stderr comes back in status: "error" as the model's correction signal.
Output budgets
Model-visible output is char-budgeted (no tokenizer dependency). Truncation always announces itself with a banner naming what was cut and the recovery move.
| Surface | Budget |
|---|---|
bash stdout / stderr | 30k chars per channel, tail-kept; full output spooled to a sandbox file |
read content | 50k chars per call, up to 2000 lines, 2000 chars per line (paginate via nextOffset) |
grep | 100 matches by default (maxMatches ≤ 1000), 30k chars, 2000 chars per line |
glob | 100 paths by default (limit ≤ 1000), 30k chars |
listDirectory | 100 entries by default (limit ≤ 200), paginate via nextOffset |
The same capability-declaring definitions are also exported individually as
sandboxBashTool, sandboxReadTool, sandboxWriteTool, sandboxEditTool,
sandboxGrepTool, sandboxGlobTool, and sandboxListDirectoryTool.
SandboxProvider
Provider package authors implement this contract. Methods take a single named-object input, return null for expected misses, and throw typed errors for exceptional failures.
type SandboxProvider = {
/**
* Answer whether the backend can serve this caller, as cheaply as the
* backend allows: a credential check and a reachability check, nothing
* more. Resolves when it can, and throws `SandboxUnavailableError` with a
* `reason` when it cannot. Use it for a status endpoint or an availability
* banner; `list()` is not a substitute, being unbounded and, on a service,
* an audited tenant operation.
*
* An implementation must not provision anything to answer it, its own lazy
* setup included, and must answer repeated calls afresh.
*/
probe(): Promise<void>;
/** Provision a new sandbox and return a live session for it. */
create(input: CreateSandboxInput): Promise<SandboxSession>;
/**
* Reattach to an existing sandbox by id, resuming it if the provider
* paused it. Returns null when the sandbox no longer exists.
*/
connect(input: ConnectSandboxInput): Promise<SandboxSession | null>;
/** List live (running or paused) sandboxes, optionally filtered by metadata. */
list(input?: ListSandboxesInput): Promise<SandboxInfo[]>;
/** Optional cleanup hook for long-lived processes. */
close?(): Promise<void>;
};
type CreateSandboxInput = {
template?: string;
ttlMs?: number;
metadata?: Record<string, string>;
/**
* At most one live sandbox per non-empty key: `create` returns the live
* sandbox already bound to the key instead of provisioning another, and the
* rest of this input is ignored (the existing sandbox keeps its template,
* ttl, metadata, and env). Concurrent creators in any process converge on
* one sandbox. Providers without an atomic primitive for this may ignore it.
*/
bindingKey?: string;
env?: Record<string, string>;
network?: SandboxNetworkConfig;
resources?: SandboxResourceConfig;
};
type SandboxNetworkConfig =
| { access: "none" }
| { access: "allowlist"; allow: string[] }
| { access: "full" };
type SandboxInfo = {
sandboxId: string;
metadata: Record<string, string>;
state: "running" | "paused";
};Outbound network access defaults to { access: "none" }. Adapters must enforce network-off-by-default even when their vendor defaults to open — an agent-driven sandbox is untrusted compute, and exfiltration is the primary risk. Opt in per sandbox with allowlist (domains, IPs, or CIDRs) or full.
SandboxSession
type SandboxSession = {
readonly sandboxId: string;
/**
* Run a command to completion. A non-zero exit code is a normal result —
* only timeouts, aborts, and missing sandboxes reject.
*/
runCommand(input: SandboxRunCommandInput): Promise<SandboxCommandResult>;
/** Read bounded file bytes. Returns null when the file does not exist. */
readFile(input: {
path: string;
/** Must be a non-negative safe integer. */
maxBytes?: number;
signal?: AbortSignal;
}): Promise<Uint8Array | null>;
/** Write a file, creating missing parent directories. */
writeFile(input: {
path: string;
data: Uint8Array | string;
signal?: AbortSignal;
}): Promise<void>;
/** List a directory. Returns null when the directory does not exist. */
listDir(input: { path: string }): Promise<SandboxDirEntry[] | null>;
/** Extend the sandbox's idle lifetime to at least `ttlMs` from now. */
keepAlive(input: { ttlMs: number }): Promise<void>;
/** Destroy the sandbox and release its resources. */
kill(): Promise<void>;
/** Optional: snapshot and stop the sandbox; `connect` resumes it. */
pause?(): Promise<void>;
};
type SandboxRunCommandInput = {
command: string;
cwd?: string;
env?: Record<string, string>;
timeoutMs?: number;
/** Combined captured stdout/stderr budget for this command. */
maxOutputBytes?: number;
onStdout?: (chunk: string) => void;
onStderr?: (chunk: string) => void;
signal?: AbortSignal;
};
type SandboxCommandResult = {
stdout: string;
stderr: string;
exitCode: number;
};
type SandboxDirEntry = {
name: string;
type: "file" | "directory";
};onStdout and onStderr receive chunks as they are produced; the concatenated chunks equal the corresponding result fields. Aborting signal rejects command and file transfers with the signal's abort reason. A positive safe-integer maxOutputBytes bounds combined captured stdout/stderr for one command, while a non-negative file-read maxBytes rejects an oversized transfer before the full file enters application memory.
Session manager
The machinery behind the capability binding and the tool bundle is exported for direct use:
import {
createSandboxSessionManager,
} from "@pai/sandbox";
const sessions = createSandboxSessionManager({
provider,
keepAliveTtlMs: 5 * 60 * 1000,
});
const handle = sessions.handle(conversationId);
const { result, environmentReset } = await handle.withSession((session) =>
session.runCommand({ command: "python analyze.py" }),
);
// On conversation deletion (terminal for already-held handles):
await sessions.dispose(conversationId);
// For a reusable reset:
await handle.reset();
// On process shutdown:
await sessions.close();Await every provider operation inside the callback. The session is a callback-scoped implementation detail and should not be retained or returned for later work.
type SandboxSessionManager = {
/** Stable handle for one session key. Does not touch the provider. */
handle(key: string): SandboxSessionHandle;
/** Terminally revoke held handles, then kill and evict the key. */
dispose(key: string): Promise<void>;
/**
* Release in-process state (per-key caches) after in-flight work drains and
* call `provider.close?.()`. Never kills sandboxes — they are durable by
* design and a fresh manager rediscovers them by metadata. Idempotent.
*/
close(): Promise<void>;
};
type SandboxSessionHandle = {
readonly key: string;
/**
* Run one operation against a live session, recovering once when the
* sandbox disappears mid-call. Concurrent operations share one sandbox.
*/
withSession<T>(
operation: (session: SandboxSession) => Promise<T>,
): Promise<SandboxSessionResult<T>>;
/**
* Run a session operation in the key's mutation queue. Exclusive operations
* serialize with one another and with reset.
*/
withSessionExclusive<T>(
operation: (session: SandboxSession) => Promise<T>,
): Promise<SandboxSessionResult<T>>;
/**
* Destroy the environment while preserving recreation history for the
* handle, so the next operation reports an environment reset.
*/
reset(): Promise<void>;
};
type SandboxSessionResult<T> = {
result: T;
/** True when the operation ran in a replacement environment. */
environmentReset: boolean;
};handle(key).withSession(...)reuses and keeps alive the cached sandbox, rediscovers it by metadata viaprovider.list, or callscreatewith the session key asbindingKey. Concurrent first operations for one key are serialized in-process, so only one create is issued. Await all provider work before the callback returns.- The manager tags every sandbox it creates with the session key under the
paiSessionKeymetadata key (DEFAULT_SESSION_KEY_METADATA_KEY, configurable), which is what makes cross-process rediscovery and orphan cleanup possible. - Right after
create, the manager renews once. A provider that implements binding keys may hand back a sandbox another replica created, whose deadline this process never set; the immediate renewal makes the cached renewal clock right either way. withSession(...)recovers once from a sandbox that disappeared mid-call and reportsenvironmentResetthe same way the tools do. UsewithSessionExclusive(...)for mutations that must serialize with other exclusive operations and reset. TheSandboxfacade wraps these same primitives.handle.reset()invalidates existing operations' retry eligibility, destroys cached and rediscovered environments, and keeps handle history so the next operation reports recreation. Pre-reset work is never retried into the replacement environment.dispose(key)first revokes the current handle, then performs the same orphan sweep. Held and in-flight work cannot reacquire or retry into a replacement after a thread is deleted; a new handle is available only after disposal completes.close()stops accepting new work, drains already-admitted session operations, clears in-process state, and then callsprovider.close?.(). It does not kill durable sandboxes.- Cross-replica coordination is the provider's job through
bindingKey. GKE implements it with a Kubernetes Lease per key, so replicas creating for one conversation at once converge on one sandbox; the in-memory provider implements it within one process, which is what its tests need. On providers that ignore the key, only metadata rediscovery guards against duplicates, and it is not atomic.
Errors
class SandboxNotFoundError extends Error {
readonly sandboxId: string;
}
class SandboxUnavailableError extends Error {
readonly reason: "transport" | "backend" | "auth" | "not-served";
readonly retryable: boolean;
readonly errorId?: string;
}
class SandboxCommandTimeoutError extends Error {
readonly command: string;
readonly timeoutMs: number;
}
class SandboxOutputLimitError extends Error {
readonly command: string;
readonly limitBytes: number;
}
class SandboxFileTooLargeError extends Error {
readonly path: string;
readonly limitBytes: number;
}
class SandboxPolicyMismatchError extends Error {}
class SandboxQuotaExceededError extends Error {}
function isSandboxNotFoundError(value: unknown): value is SandboxNotFoundError;
function isSandboxUnavailableError(
value: unknown,
): value is SandboxUnavailableError;
function isSandboxCommandTimeoutError(
value: unknown,
): value is SandboxCommandTimeoutError;
function isSandboxOutputLimitError(
value: unknown,
): value is SandboxOutputLimitError;
function isSandboxFileTooLargeError(
value: unknown,
): value is SandboxFileTooLargeError;
function isSandboxPolicyMismatchError(
value: unknown,
): value is SandboxPolicyMismatchError;
function isSandboxQuotaExceededError(
value: unknown,
): value is SandboxQuotaExceededError;-
SandboxNotFoundError— any session operation targeting a sandbox that was expired, killed, or reclaimed. The session manager treats it as the recreate signal.provider.connectdoes not throw it; a gone sandbox is an expected miss there, so it returns null. -
SandboxUnavailableError— the backend is unreachable or refuses service. Recreating the sandbox will not help.reasonsays which it was:transport(never reached),backend(it answered and the failure is its own),auth(it refused the credential), ornot-served(it answered but does not serve this caller or this route, so the url or the project is wrong).retryabledefaults to true for the first two and false for the last two, and a backend that reported its own id for the failure passes it aserrorId, so both sides can find it in a log. -
SandboxCommandTimeoutError— a command exceeded thetimeoutMsgiven torunCommand. -
SandboxOutputLimitError— captured stdout/stderr exceeded a provider's configured byte budget. -
SandboxFileTooLargeError— an upload or download exceeded a provider's transfer-size policy. -
SandboxPolicyMismatchError— the request asked for something this deployment's policy does not allow: a template the caller may not use, a network mode the template does not enforce, a ttl above a project cap. The request is wrong for this deployment rather than malformed, so it is not aTypeError, and retrying unchanged will not help. -
SandboxQuotaExceededError— a create was refused because a quota is exhausted, such as a project's concurrent-sandbox cap or a namespace ResourceQuota. Retrying may succeed once other sandboxes are released. -
SandboxInputError— a call the contract itself forbids: an invalid limit, an emptybindingKey, an option a provider cannot carry. It extendsTypeError, soinstanceof TypeErrorstill holds, but it is named so a boundary such as the sandbox service can tell a caller's mistake from a bug that also throwsTypeError.
Conformance suite
Provider implementations validate against the shared suite from @pai/sandbox/test. It covers lifecycle, metadata listing, command execution (streaming, cwd, env, timeout, abort), file round-trips, and error semantics after kill. Providers that implement bindingKey pass bindingKeys: true to add the one-sandbox-per-key cases, including concurrent creates.
import { createSandboxProviderConformanceSuite } from "@pai/sandbox/test";
createSandboxProviderConformanceSuite({
name: "createMySandboxProvider",
createProvider: () => createMySandboxProvider(),
});Runtimes that are not a POSIX shell can override the shell recipes the suite runs:
type SandboxConformanceCommands = {
helloStdout: string; // prints "hello" on stdout, exit 0
stderrExit3: string; // prints "oops" on stderr, exit 3
longRunning: string; // runs >= 10s unless killed
printCwd: string; // prints the working directory
printEnvVar: string; // prints $PAI_CONFORMANCE
};
createSandboxProviderConformanceSuite({
name: "createMySandboxProvider",
createProvider: () => createMySandboxProvider(),
commands: { stderrExit3: "my-fail-command" },
});sandboxCapability additionally passes the generic capability binding conformance suite from @pai/core/test, which pins the keying and lifecycle contract the runtime relies on.