PAIPAI

@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 edit

Descriptions 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:

  • acquire is cheap — a lazy per-tool-call Sandbox facade that only touches the provider on first use.
  • thread-deletion dispose kills every sandbox for the instance key and evicts its manager state, including orphans rediscovered by metadata.
  • close (runtime shutdown) releases in-process state and calls provider.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.

ToolInputOutput
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 timeoutMs for 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 stdout and stderr data 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 can grep or read it.

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.

SurfaceBudget
bash stdout / stderr30k chars per channel, tail-kept; full output spooled to a sandbox file
read content50k chars per call, up to 2000 lines, 2000 chars per line (paginate via nextOffset)
grep100 matches by default (maxMatches ≤ 1000), 30k chars, 2000 chars per line
glob100 paths by default (limit ≤ 1000), 30k chars
listDirectory100 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 via provider.list, or calls create with the session key as bindingKey. 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 paiSessionKey metadata 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 reports environmentReset the same way the tools do. Use withSessionExclusive(...) for mutations that must serialize with other exclusive operations and reset. The Sandbox facade 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 calls provider.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.connect does 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. reason says which it was: transport (never reached), backend (it answered and the failure is its own), auth (it refused the credential), or not-served (it answered but does not serve this caller or this route, so the url or the project is wrong). retryable defaults 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 as errorId, so both sides can find it in a log.

  • SandboxCommandTimeoutError — a command exceeded the timeoutMs given to runCommand.

  • 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 a TypeError, 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 empty bindingKey, an option a provider cannot carry. It extends TypeError, so instanceof TypeError still holds, but it is named so a boundary such as the sandbox service can tell a caller's mistake from a bug that also throws TypeError.

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.

On this page