PAIPAI

@pai/sandbox-inmemory

Scriptable in-process SandboxProvider implementation for tests and demos.

Package: @pai/sandbox-inmemory

Fakes a sandbox filesystem and a scriptable command runtime in memory. Nothing is isolated and state is lost when the provider instance is discarded — use it for agent tests, demos, and examples.

It is faithful to the contract a real adapter honors: expired and killed sandboxes throw SandboxNotFoundError from every session operation, connect returns null for gone sandboxes and resumes paused ones, create({ bindingKey }) returns the live sandbox already bound to the key, and command timeouts and aborts reject the same way a real runtime does. Expiry-recreate flows through the session manager and tool bundle are fully testable without a network.

Quick start

Swap it in as the capability binding — the production agent stays verbatim:

import { createTestRuntime } from "@pai/test-utils";
import { sandboxCapability } from "@pai/sandbox";
import { createInMemorySandboxProvider } from "@pai/sandbox-inmemory";

await using runtime = createTestRuntime({
  agent,
  model,
  capabilities: {
    sandbox: sandboxCapability({ provider: createInMemorySandboxProvider() }),
  },
});

Options

type InMemorySandboxProviderOptions = {
  /** Scripted commands, checked in order before the built-ins. First match wins. */
  commands?: InMemoryCommandSpec[];

  /** Injectable clock for TTL bookkeeping. Defaults to `Date.now`. */
  now?: () => number;

  /** Default idle lifetime for created sandboxes. Defaults to 5 minutes. */
  defaultTtlMs?: number;

  /** Override how sandbox ids are minted. */
  generateId?: () => string;
};

Commands

Built-ins cover what agents commonly run: echo, pwd, printenv, sleep, cat, and ls. Anything else exits 127 with command not found on stderr. Script the commands your test needs with commands:

const provider = createInMemorySandboxProvider({
  commands: [
    { match: "git status", handler: () => ({ stdout: "clean\n" }) },
    {
      match: /^npm test/,
      handler: ({ emitStdout, files }) => {
        emitStdout("1 passed\n");
        files.write("report.json", '{"passed":1}');
      },
    },
  ],
});
type InMemoryCommandSpec = {
  /** A string matches the full command exactly; RegExp and predicate as expected. */
  match: string | RegExp | ((command: string) => boolean);
  handler: InMemoryCommandHandler;
};

type InMemoryCommandHandler = (
  invocation: InMemoryCommandInvocation,
) => InMemoryCommandResult | void | Promise<InMemoryCommandResult | void>;

type InMemoryCommandInvocation = {
  command: string;
  /** Tokenized with single/double-quote support. */
  argv: string[];
  cwd: string;
  env: Record<string, string>;
  /** Stream output live to the caller's onStdout/onStderr. */
  emitStdout(chunk: string): void;
  emitStderr(chunk: string): void;
  /** Fires on timeout or caller abort. */
  signal: AbortSignal;
  /** Sandbox filesystem access. Relative paths resolve against cwd. */
  files: InMemoryCommandFiles;
};

type InMemoryCommandResult = {
  stdout?: string;
  stderr?: string;
  /** Defaults to 0. */
  exitCode?: number;
};

Handlers stream through emitStdout/emitStderr or return output in the result, and read and write the sandbox filesystem via files. Throwing rejects runCommand; return a non-zero exit code to simulate a failing command.

Session file operations and the default command cwd resolve relative paths against IN_MEMORY_SANDBOX_HOME (/home/user).

Simulating expiry

TTL bookkeeping uses the injectable now clock, so expiry-recreate flows are testable without real timers:

let now = 0;
const provider = createInMemorySandboxProvider({ now: () => now });
const session = await provider.create({ ttlMs: 1_000 });

now += 1_000;
// The sandbox is now gone: session operations throw SandboxNotFoundError
// and provider.connect() returns null, so the session manager recreates.

Command timeouts and the sleep built-in always use real timers.

Conformance

createInMemorySandboxProvider passes the full @pai/sandbox/test conformance suite. Two of the suite's shell recipes are overridden with scripted equivalents because the fake is not a POSIX shell:

import { createSandboxProviderConformanceSuite } from "@pai/sandbox/test";
import { createInMemorySandboxProvider } from "@pai/sandbox-inmemory";

createSandboxProviderConformanceSuite({
  name: "createInMemorySandboxProvider",
  createProvider: () =>
    createInMemorySandboxProvider({
      commands: [
        {
          match: "fail-with-oops",
          handler: () => ({ stderr: "oops", exitCode: 3 }),
        },
      ],
    }),
  commands: {
    stderrExit3: "fail-with-oops",
    printEnvVar: "printenv PAI_CONFORMANCE",
  },
});

On this page