How to Create a Custom Sandbox Provider in Sandcastle Using createIsolatedSandboxProvider

Create a custom sandbox provider by implementing the IsolatedSandboxHandle interface—defining exec, copyIn, copyFileOut, and close methods—and wrapping it with createIsolatedSandboxProvider from src/SandboxProvider.ts to enable isolated agent execution in any environment.

Sandcastle treats sandboxes as plugins that manage the lifecycle of code execution environments. When you need to integrate a custom runtime—whether a local temporary directory, a Docker container, or a remote cloud VM—you can build a compliant provider using the createIsolatedSandboxProvider helper. This function is exported from src/SandboxProvider.ts in the mattpocock/sandcastle repository and implements the IsolatedSandboxProvider interface required by the framework.

Understanding the Isolated Sandbox Contract

The core contract for isolated sandboxes is defined in src/SandboxProvider.ts. According to the source code, an isolated provider must satisfy four operational requirements:

  1. Create a sandbox handle for a given worktree.
  2. Execute commands inside the sandbox while optionally streaming output line-by-line.
  3. Transfer files between the host filesystem and the sandbox.
  4. Tear down resources when the session ends.

The IsolatedSandboxProvider interface expects a create function that returns an IsolatedSandboxHandle. This handle must expose five specific properties and methods: worktreePath (string), exec, copyIn, copyFileOut, and close. The provider object returned by createIsolatedSandboxProvider includes a discriminant tag: "isolated" that allows Sandcastle to dispatch the correct implementation path at runtime.

Step-by-Step Implementation Guide

1. Define Provider Configuration (Optional)

If your sandbox requires configuration—such as API keys, endpoint URLs, or resource limits—define a TypeScript interface similar to DaytonaOptions found in src/sandboxes/daytona.ts. This options interface is passed to your provider factory function.

2. Invoke createIsolatedSandboxProvider

Import createIsolatedSandboxProvider from ../SandboxProvider.js (or the package export). This helper constructs the provider object and handles environment merging with the global EnvResolver system. You must provide:

  • name: A string identifier for the provider.
  • env (optional): Default environment variables injected into the sandbox.
  • create: An async factory function that builds and returns the IsolatedSandboxHandle.

3. Implement the Sandbox Handle

The create function must return an object implementing IsolatedSandboxHandle with these methods:

  • worktreePath: Absolute path to the working directory inside the sandbox.
  • exec(command, opts): Executes shell commands. Must support an optional onLine callback in opts to stream stdout line-by-line for live feedback and idle-timeout enforcement.
  • copyIn(hostPath, sandboxPath): Recursively copies files or directories from the host into the sandbox.
  • copyFileOut(sandboxPath, hostPath): Copies a single file from the sandbox back to the host (host path is absolute).
  • close(): Cleans up resources (removes temp directories, stops containers, or terminates VMs).

4. Export the Provider

Export a factory function (e.g., myFsIsolated) that returns the IsolatedSandboxProvider. Users import this function and pass its result to run(), interactive(), or createSandbox().

Complete Example: Filesystem-Based Provider

The following implementation creates a temporary directory-based sandbox, following the pattern used in src/sandboxes/test-isolated.ts. It demonstrates streaming execution, recursive file copying, and proper cleanup.

// src/sandboxes/my-fs-isolated.ts
import {
  createIsolatedSandboxProvider,
  type IsolatedSandboxProvider,
  type IsolatedSandboxHandle,
  type ExecResult,
} from "../SandboxProvider.js";

export const myFsIsolated = (): IsolatedSandboxProvider =>
  createIsolatedSandboxProvider({
    name: "my-fs-isolated",
    env: { MY_VAR: "value" },

    create: async (): Promise<IsolatedSandboxHandle> => {
      const { mkdtemp, rm, mkdir, cp, stat } = await import("node:fs/promises");
      const { tmpdir } = await import("node:os");
      const { join, dirname } = await import("node:path");
      
      const sandboxRoot = await mkdtemp(join(tmpdir(), "my-sandbox-"));
      const worktreePath = join(sandboxRoot, "workspace");
      await mkdir(worktreePath, { recursive: true });

      return {
        worktreePath,

        exec: (command, opts) => {
          const { execFile, spawn } = await import("node:child_process");
          const { createInterface } = await import("node:readline");

          if (opts?.onLine) {
            const onLine = opts.onLine;
            return new Promise<ExecResult>((resolve, reject) => {
              const proc = spawn("sh", ["-c", command], {
                cwd: opts?.cwd ?? worktreePath,
                stdio: ["ignore", "pipe", "pipe"],
              });

              const stdoutLines: string[] = [];
              const stderrChunks: string[] = [];

              const rl = createInterface({ input: proc.stdout! });
              rl.on("line", (line) => {
                stdoutLines.push(line);
                onLine(line);
              });

              proc.stderr!.on("data", (c) => stderrChunks.push(c.toString()));
              proc.on("error", (e) => reject(e));
              proc.on("close", (code) =>
                resolve({
                  stdout: stdoutLines.join("\n"),
                  stderr: stderrChunks.join(""),
                  exitCode: code ?? 0,
                })
              );
            });
          }

          return new Promise<ExecResult>((resolve, reject) => {
            execFile(
              "sh",
              ["-c", command],
              { cwd: opts?.cwd ?? worktreePath },
              (error, stdout, stderr) => {
                if (error && error.code === undefined) reject(error);
                else
                  resolve({
                    stdout: stdout.toString(),
                    stderr: stderr.toString(),
                    exitCode: typeof error?.code === "number" ? error.code : 0,
                  });
              }
            );
          });
        },

        copyIn: async (hostPath, sandboxPath) => {
          const info = await stat(hostPath);
          if (info.isDirectory())
            await cp(hostPath, sandboxPath, { recursive: true });
          else {
            await mkdir(dirname(sandboxPath), { recursive: true });
            await cp(hostPath, sandboxPath);
          }
        },

        copyFileOut: async (sandboxPath, hostPath) => {
          await mkdir(dirname(hostPath), { recursive: true });
          await cp(sandboxPath, hostPath);
        },

        close: async () => {
          await rm(sandboxRoot, { recursive: true, force: true });
        },
      };
    },
  });

To use this provider in a Sandcastle execution:

import { run } from "sandcastle";
import { myFsIsolated } from "./sandboxes/my-fs-isolated.js";

await run({
  sandboxProvider: myFsIsolated(),
  // Additional run configuration (prompt, model, etc.)
});

Remote API Integration Pattern

For remote sandboxes (e.g., cloud VMs), follow the architecture in src/sandboxes/daytona.ts. The skeleton remains identical, but the create function initializes an API client instead of a local directory:

export const myRemote = (opts?: MyRemoteOptions): IsolatedSandboxProvider =>
  createIsolatedSandboxProvider({
    name: "my-remote",
    env: opts?.env,
    create: async () => {
      const client = new RemoteClient(opts?.apiKey, opts?.apiUrl);
      const sandbox = await client.startSandbox(opts?.config);
      const worktreePath = await sandbox.getWorkDir();

      return {
        worktreePath,
        exec: (command, opts) => client.exec(command, opts),
        copyIn: (hostPath, sandboxPath) => client.uploadFile(hostPath, sandboxPath),
        copyFileOut: (sandboxPath, hostPath) => client.downloadFile(sandboxPath, hostPath),
        close: () => client.stopSandbox(sandbox.id),
      };
    },
  });

Key Architectural Details

Streaming Execution: The exec method must call opts.onLine for each line of stdout as soon as it is produced. Sandcastle uses this callback to enforce idle timeouts and provide real-time feedback. If onLine is not provided, the method should still return an ExecResult containing the full stdout, stderr, and exit code.

Environment Merging: Any env declared in the provider config is merged with the global environment resolver (see src/EnvResolver.ts) before the sandbox launches. This allows users to override defaults while preserving system-wide configuration.

Resource Lifecycle: The close method must be idempotent and thorough. Sandcastle automatically invokes close when the sandbox is disposed, but your implementation must handle partial failures gracefully to avoid leaking temporary directories or orphaning remote VMs.

Summary

  • Import createIsolatedSandboxProvider from src/SandboxProvider.ts to build compliant isolated providers.
  • Implement the five required handle methods: worktreePath, exec, copyIn, copyFileOut, and close.
  • Support the onLine callback in exec to enable Sandcastle's streaming output and idle detection features.
  • Use src/sandboxes/test-isolated.ts as a reference for filesystem-based sandboxes and src/sandboxes/daytona.ts for remote API patterns.
  • Export a factory function that returns the provider, then pass it to run() or createSandbox().

Frequently Asked Questions

What is the difference between an isolated provider and other sandbox types in Sandcastle?

Isolated providers create completely separate execution environments with their own filesystems and process spaces, requiring explicit copyIn and copyFileOut operations for file transfer. According to the source code in src/SandboxProvider.ts, isolated providers are distinguished by the tag: "isolated" property and implement handle-based lifecycle management. Other provider types may offer different isolation guarantees or file access patterns, but isolated providers are required when you need guaranteed separation between the host and agent execution contexts.

How do I handle long-running processes or timeouts in the exec method?

When implementing exec in your custom provider, you must respect the onLine callback to prevent idle timeouts. Sandcastle monitors this callback activity to determine if the process is still alive and producing output. Your implementation should spawn the process with streaming stdout, pipe each line to onLine immediately, and resolve the returned promise only when the process exits. Ensure you handle process termination signals properly in the close method to avoid zombie processes.

Can I use environment variables from the host machine in my custom sandbox?

Yes, but they are merged through Sandcastle's environment resolution system. When you specify an env object in createIsolatedSandboxProvider, those values are combined with the global environment resolver defined in src/EnvResolver.ts. The merged environment is then injected into the sandbox. You should not assume direct access to process.env inside the sandbox handle unless you explicitly pass those values through the provider configuration.

Where should I place my custom provider files in the project structure?

Create your provider implementation in src/sandboxes/<your-provider>.ts following the convention established by daytona.ts and test-isolated.ts in the Sandcastle repository. This location keeps sandbox implementations organized alongside the core types. If you are consuming Sandcastle as a dependency rather than modifying the source, you can place the provider file anywhere in your project and import createIsolatedSandboxProvider from the sandcastle package instead of a relative path.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →