Skip to content
Français

Add a sandbox provider

Connect an execution environment that runs commands, transfers files and releases resources.

Implement a SandboxProvider to open an execution environment. It returns a SandboxLease through which Outpost runs commands and transfers files. The example below outlines a virtual machine integration; vms represents that platform’s SDK.

Adapt these SDK contracts and command helpers to your VM platform.

export interface Vm {
  run(
    argv: readonly string[],
    options: {
      cwd: string;
      env: Record<string, string>;
      stdin?: string | undefined;
      signal: AbortSignal;
      onOutput?:
        ((channel: "stdout" | "stderr", text: string) => void) | undefined;
    },
  ): Promise<{ exitCode: number; stdout: string; stderr: string }>;
  put(local: string, remote: string, signal: AbortSignal): Promise<void>;
  get(remote: string, local: string, signal: AbortSignal): Promise<void>;
}
import type { Vm } from "./vm.types.ts";

export declare const vms: {
  create(name: string, signal?: AbortSignal): Promise<Vm>;
  remove(name: string, signal?: AbortSignal): Promise<void>;
};
import type { TransferOptions } from "@elie-laloum/outpost";

export const bounded = ({ signal, deadlineMs }: TransferOptions) =>
  AbortSignal.any([
    ...(signal ? [signal] : []),
    ...(deadlineMs ? [AbortSignal.timeout(deadlineMs)] : []),
  ]);
import type { SandboxContext, Command } from "@elie-laloum/outpost";
import { bounded } from "./deadline.ts";

export function commandOptions(context: SandboxContext, command: Command) {
  return {
    cwd: command.directory ?? "/workspace",
    env: { ...context.variables, ...command.variables },
    stdin: command.stdin,
    signal: bounded(command),
    onOutput: command.observe,
  };
}
import type { Vm } from "./vm.types.ts";
import type { SandboxContext, SandboxLease } from "@elie-laloum/outpost";
import { commandOptions } from "./command-options.ts";

export function invokeVm(
  vm: Vm,
  context: SandboxContext,
): SandboxLease["invoke"] {
  return async (command) => {
    const result = await vm.run(
      [command.executable, ...(command.arguments ?? [])],
      commandOptions(context, command),
    );
    return {
      status: result.exitCode,
      stdout: result.stdout,
      stderr: result.stderr,
    };
  };
}

Add transfers and idempotent release, then compose the provider in vm-provider.ts.

import type { Vm } from "./vm.types.ts";
import type { SandboxLease } from "@elie-laloum/outpost";
import { bounded } from "./deadline.ts";

export function transferVm(vm: Vm): Pick<SandboxLease, "upload" | "download"> {
  return {
    upload: (source, destination, options = {}) =>
      vm.put(source, destination, bounded(options)),
    download: (source, destination, options = {}) =>
      vm.get(source, destination, bounded(options)),
  };
}
import type { Vm } from "./vm.types.ts";
import type { SandboxLease } from "@elie-laloum/outpost";
import { transferVm } from "./transfer-vm.ts";

export function createVmLease(
  vm: Vm,
  invoke: SandboxLease["invoke"],
  remove: () => Promise<void>,
): SandboxLease {
  let released: Promise<void> | undefined;
  return {
    root: "/workspace",
    home: "/home/agent",
    invoke,
    ...transferVm(vm),
    release: () => (released ??= remove()),
  };
}
import { createRemoteSandboxProvider } from "@elie-laloum/outpost";
import { randomUUID } from "node:crypto";
import { vms } from "./vm-sdk.types.ts";
import { createVmLease } from "./vm-lease.ts";
import { invokeVm } from "./invoke-vm.ts";

export const vmSandboxProvider = createRemoteSandboxProvider({
  name: "vm",
  async acquire(context) {
    const name = `outpost-${randomUUID()}`;
    const vm = await vms.create(name, context.signal);
    return createVmLease(vm, invokeVm(vm, context), () => vms.remove(name));
  },
});

Pass vmSandboxProvider as sandboxProvider to dispatch() or createSandbox(). Outpost calls acquire() once per sandbox, runs the agent and your commands through invoke(), then calls release().

API reference: SandboxLease, SandboxContext and FileTransfers.

invoke() also receives retain (bytes of output tail to keep), interactive and terminal streams for attach(), and input when the lease declares liveInput.

Both helpers take name, optional variables and acquire, check the name and freeze the result. They differ in the placement they set, which decides who moves the repository.

createMountedSandboxProvider()createRemoteSandboxProvider()
Your acquire()Mounts context.directory at root and context.gitDirectories so git worksStarts an empty environment
RepositoryThe agent edits the host worktree directlyOutpost runs git init in root, uploads the history, then downloads and applies the new commits
Agent CLIComes from your imageInstalled in home when missing, unless bootstrap: false
BranchAny branch modenamed or integrate, integrate by default
Built-in examplesDocker and Podman (Docker and Podman)Vercel, Daytona, Firecracker (Cloud sandboxes)

Outpost’s supervision, retries and recovery rely on these behaviours. Each one is observable by a user when it breaks.

  • Exit statusinvoke() resolves when the process exits, with its real status, even if stdout and stderr closed earlier.
  • Cancellationsignal and deadlineMs stop the process group and its descendants; the sandbox stays usable.
  • Transfer limitsupload() and download() honour signal and deadlineMs too.
  • Exact bytesTransfers copy binary data without text decoding and keep supported modes and symlinks.
  • Safe stagingReject destinations that escape their target; remove temporary staging on success, failure and cancellation.
  • Idempotent releaseA second release() resolves without error.

Outpost uses a capability only when the provider or lease declares it; it never infers one from the provider’s name.

API reference: SandboxLease.

Without liveInput, steering a resumable CLI agent stops its process once the conversation is known and resumes it in the same sandbox.

A durable race registers each sandbox before it exists, so a restarted coordinator can remove it. In acquire(), await context.registerRecovery(resourceId) exactly once, before allocating. recover(resourceId, { signal, deadlineMs }) then removes that resource.

import type { SandboxLease } from "@elie-laloum/outpost";

export declare function startVm(
  name: string,
  signal?: AbortSignal,
): Promise<SandboxLease>;
export declare function removeVm(
  name: string,
  signal?: AbortSignal,
): Promise<void>;
import { createRemoteSandboxProvider } from "@elie-laloum/outpost";
import { randomUUID } from "node:crypto";
import { startVm, removeVm } from "./vm-lifecycle.types.ts";
import type { SandboxProvider } from "@elie-laloum/outpost";

export const provider = createRemoteSandboxProvider({
  name: "vm",
  async acquire(context) {
    const name = `outpost-${randomUUID()}`;
    await context.registerRecovery?.(name);
    return startVm(name, context.signal);
  },
});
export const durableVmProvider: SandboxProvider = {
  ...provider,
  recover: (resourceId, options) => removeVm(resourceId, options?.signal),
};

registerRecovery exists only during a durable race. If it rejects, do not allocate. recover() must succeed when called again and delete only the sandbox, never repository data on the host.

diagnoseSandbox() probes a lease: Node.js, Git, separate output streams, a nonzero exit status, the home directory and, with transfers, a binary upload verified by a process in the sandbox.

import type { SandboxProvider } from "@elie-laloum/outpost";
import { repository } from "./outpost.config.ts";
import { join } from "node:path";

export declare const vmSandboxProvider: SandboxProvider;
export function openDiagnosticLease() {
  return vmSandboxProvider.acquire({
    repository,
    directory: repository,
    gitDirectories: [join(repository, ".git")],
    variables: {},
  });
}
import { reportValue } from "./reporter.ts";
import { openDiagnosticLease, vmSandboxProvider } from "./diagnostic-lease.ts";
import { diagnoseSandbox } from "@elie-laloum/outpost";

export const lease = await openDiagnosticLease();
try {
  const report = await diagnoseSandbox(lease, {
    transfers: true,
    sandboxProvider: vmSandboxProvider,
  });
  reportValue(report.hasFailures, report.checks);
  // Example output: false [ { id: "sandbox.node", status: "pass", … }, … ]
} finally {
  await lease.release();
}

The diagnosis leaves the lease to you: release it yourself. Then run a real dispatch() on a named branch; Diagnostics reads the report.

  • No recover in the helpers: createMountedSandboxProvider() and createRemoteSandboxProvider() accept name, variables and acquire; add recover by spreading the result, as above.
  • Remote needs Git: A remote sandbox needs git on its PATH and a writable root.
  • Partial transfer probe: transfers: true checks one binary file. Symlinks, modes, directories and batch transfers stay unverified.

API: SandboxProvider · SandboxLease · SandboxContext · FileTransfers · createMountedSandboxProvider · createRemoteSandboxProvider · diagnoseSandbox.