Skip to content

Sandboxes ​

The sandbox packages: @chamvi/sandbox (contracts, rules, the workspace manager) and the three providers. See Workspaces & Coding for the behaviour.

@chamvi/sandbox ​

bash
npm install @chamvi/sandbox @chamvi/core ai --save

@chamvi/core and ai are peer dependencies.

createWorkspaceManager ​

ts
function createWorkspaceManager(options: WorkspaceManagerOptions): IWorkspaceManager;

const MAX_WORKSPACES_PER_RUN: 3;
const GC_INTERVAL_MS: 600_000;
const CLOSE_TIMEOUT_MS: 30_000;

Throws InvalidAgentDefinitionError for a provider without create and collectGarbage, an empty image, invalid env or limits, an allowRepo or onError that is no function, a maxWorkspacesPerRun below 1 or a negative gcIntervalMs.

ts
/*
 * Copyright (c) 2026.
 * Author Peter Placzek (tada5hi)
 * For the full copyright and license information,
 * view the LICENSE file that was distributed with this source code.
 */

import type { ToolSet } from 'ai';
import type { Engine, IClock } from '@chamvi/core';
import type { ISandboxProvider, SandboxLimits } from '../provider/types.ts';
import type { IWorkspace } from '../workspace/types.ts';

export type WorkspaceManagerOptions = {
    provider: ISandboxProvider;
    /** Passed to every `create`. */
    image?: string;
    /** Passed to every `create`. */
    env?: Record<string, string>;
    /** Passed to every `create`. */
    limits?: SandboxLimits;
    /** Accept `file:///absolute/path` repositories (local development and tests). Default false. */
    allowFileRepos?: boolean;
    /** Narrows the accepted repositories further; gets the normalized URL. */
    allowRepo?: (url: string) => boolean;
    /** Workspaces one run may hold at once. Default MAX_WORKSPACES_PER_RUN (3). */
    maxWorkspacesPerRun?: number;
    /** Minimum interval between the garbage collections `acquire` starts. Default GC_INTERVAL_MS; 0 disables them. */
    gcIntervalMs?: number;
    clock?: IClock;
    /** Background failures (disposal, garbage collection). Default console.error. */
    onError?: (error: unknown) => void;
};

export type AcquireInput = {
    /** `owner/name`, an https URL, or `file:///absolute/path` with `allowFileRepos`. */
    repo?: string;
    ref?: string;
    signal?: AbortSignal;
};

export type CloseOptions = {
    /** How long close() waits for disposals and creates in flight. Default CLOSE_TIMEOUT_MS (30 s). */
    timeoutMs?: number;
};

export type AcquireResult = {
    workspace: IWorkspace;
    /** True when the run already held (or was creating) the workspace of this repository. */
    reused: boolean;
};

/**
 * Run-scoped workspaces: one per run and repository (or none), created
 * through the provider and disposed when the run settles (`attach`).
 */
export interface IWorkspaceManager {
    /**
     * The run's workspace of `repo` (none: the repo-less one), created when
     * missing, expired or gone (`IWorkspace.disposed`). Concurrent calls share one create. ValidationError for a
     * repository or ref the rules refuse; SandboxError for another ref of a
     * held repository, a reached per-run cap or a failed create.
     */
    acquire(runId: string, input: AcquireInput): Promise<AcquireResult>;
    /**
     * The run's workspace by id, or its only live one when id is omitted
     * (expired and gone ones are disposed first). SandboxError: unknown,
     * expired or gone (disposed then), none; ValidationError: several and no id.
     */
    get(runId: string, workspaceId?: string): IWorkspace;
    /**
     * Disposes one workspace (SandboxError /not found/ for an id the run does
     * not hold, like get), or all of the run (never rejects for none); the
     * latter aborts the run's creates in flight and waits for them.
     */
    release(runId: string, workspaceId?: string): Promise<void>;
    /**
     * Disposes a run's workspaces on completed | failed | cancelled. Returns the
     * unsubscribe; after close() it subscribes nothing.
     */
    attach(engine: Pick<Engine, 'on'>): () => void;
    /** `create_workspace` and `dispose_workspace`. */
    tools(): ToolSet;
    /** Disposes the manager's expired workspaces, then runs the provider's collection; returns how many went. */
    collectGarbage(): Promise<number>;
    /**
     * Unsubscribes every attach, aborts the creates in flight and disposes
     * every workspace, waiting for both, for disposals started earlier and for
     * the creates in flight of runs released before (call after engine.drain()); afterwards
     * acquire rejects and attach subscribes nothing. Resolves after `timeoutMs`
     * at the latest, reporting what is still being disposed to `onError`.
     */
    close(options?: CloseOptions): Promise<void>;
}

export type WorkspaceToolResult = {
    workspace: string;
    root: '/workspace';
    repo: string | null;
    ref: string | null;
    commit: string | null;
    expiresAt: string;
    reused: boolean;
};

export type DisposeWorkspaceResult = {
    workspace: string;
    disposed: true;
};

Workspaces and providers ​

ts
/*
 * Copyright (c) 2026.
 * Author Peter Placzek (tada5hi)
 * For the full copyright and license information,
 * view the LICENSE file that was distributed with this source code.
 */

/** What a provider knows about a workspace; `commit` is set after the checkout. */
export type WorkspaceInfo = {
    runId: string;
    /** `ISandboxProvider.name` of the provider that created it. */
    provider: string;
    /** The normalized repository URL. */
    repo?: string;
    ref?: string;
    /** HEAD after the checkout. */
    commit?: string;
    createdAt: Date;
    expiresAt: Date;
};

export type ExecOptions = {
    /** Relative to the workspace root or under `/workspace/`; default the root. */
    cwd?: string;
    /** Default `EXEC_TIMEOUT_MS`, at most `EXEC_TIMEOUT_MAX_MS`. */
    timeoutMs?: number;
    stdin?: string;
    signal?: AbortSignal;
};

export type ExecResult = {
    exitCode: number;
    /** At most `OUTPUT_MAX_BYTES` bytes of UTF-8. */
    stdout: string;
    stderr: string;
    stdoutTruncated: boolean;
    stderrTruncated: boolean;
    timedOut: boolean;
    durationMs: number;
};

/**
 * A disposable directory a run executes commands in. Exec takes argv only,
 * never a shell string.
 */
export interface IWorkspace {
    readonly id: string;
    readonly info: WorkspaceInfo;
    /**
     * True after `dispose()`, or once the workspace found its container, pod
     * or directory gone (stopped, killed or removed outside chamvi). Every
     * call then rejects with SandboxError; the manager drops such a workspace
     * and disposes it, which removes what is left of it.
     */
    readonly disposed: boolean;
    exec(argv: readonly string[], options?: ExecOptions): Promise<ExecResult>;
    /** UTF-8 text of at most `FILE_MAX_BYTES`. */
    readFile(path: string): Promise<string>;
    writeFile(path: string, content: string): Promise<void>;
    /** Idempotent. Every call after it rejects with SandboxError. */
    dispose(): Promise<void>;
}
ts
/*
 * Copyright (c) 2026.
 * Author Peter Placzek (tada5hi)
 * For the full copyright and license information,
 * view the LICENSE file that was distributed with this source code.
 */

import type { IWorkspace } from '../workspace/types.ts';

export type SandboxLimits = {
    /** Kubernetes quantity, e.g. `'1'`, `'500m'`. */
    cpu?: string;
    /** Bytes as a Kubernetes quantity, e.g. `'2Gi'`. */
    memory?: string;
    /** Disk of `/workspace`, e.g. `'4Gi'`. */
    storage?: string;
    pids?: number;
    /** Lifetime of the workspace. */
    timeoutMs?: number;
};

export type WorkspaceCreateOptions = {
    runId: string;
    /** A normalized repository URL (`normalizeRepo`); without it the workspace is only `git init`-ed. */
    repo?: string;
    ref?: string;
    image?: string;
    env?: Record<string, string>;
    limits?: SandboxLimits;
    signal?: AbortSignal;
};

export interface ISandboxProvider {
    readonly name: string;
    /**
     * A ready workspace (id `randomUUID()`), checked out when `repo` is given
     * (via `prepareWorkspace`, then `info.commit = commit`).
     */
    create(options: WorkspaceCreateOptions): Promise<IWorkspace>;
    /**
     * Deletes this provider's workspaces past their expiry; returns how many.
     * One that cannot be removed never keeps the others: the pass goes on and
     * rejects with one SandboxError at the end.
     */
    collectGarbage(): Promise<number>;
}
ts
/*
 * Copyright (c) 2026.
 * Author Peter Placzek (tada5hi)
 * For the full copyright and license information,
 * view the LICENSE file that was distributed with this source code.
 */

/** The workspace root as the model sees it, on every provider. */
export const WORKSPACE_ROOT = '/workspace';

/** Cap per output stream of one exec. */
export const OUTPUT_MAX_BYTES = 65_536;

/** Cap of `readFile` and `writeFile`. */
export const FILE_MAX_BYTES = 1_048_576;

export const EXEC_TIMEOUT_MS = 300_000;

export const EXEC_TIMEOUT_MAX_MS = 1_800_000;

/**
 * The whole environment of a container or pod workspace (plus the configured
 * `env`). `safe.directory` because `/workspace` is a root-owned volume.
 */
export const SANDBOX_ENV: Readonly<Record<string, string>> = Object.freeze({
    HOME: '/tmp',
    GIT_TERMINAL_PROMPT: '0',
    GIT_CONFIG_NOSYSTEM: '1',
    GIT_CONFIG_COUNT: '1',
    GIT_CONFIG_KEY_0: 'safe.directory',
    GIT_CONFIG_VALUE_0: WORKSPACE_ROOT,
    NPM_CONFIG_UPDATE_NOTIFIER: 'false',
    CI: 'true',
});
ts
/*
 * Copyright (c) 2026.
 * Author Peter Placzek (tada5hi)
 * For the full copyright and license information,
 * view the LICENSE file that was distributed with this source code.
 */

import type { SandboxLimits } from './types.ts';

export const DEFAULT_LIMITS: Readonly<Required<SandboxLimits>> = Object.freeze({
    cpu: '1',
    memory: '2Gi',
    storage: '4Gi',
    pids: 512,
    timeoutMs: 1_800_000,
});

export const DEFAULT_SANDBOX_IMAGE = 'ghcr.io/chamvi/sandbox-node:24';

/** Size of the `/tmp` tmpfs of a container or pod workspace. */
export const TMP_SIZE = '512Mi';

/** Labels every container or pod workspace carries. */
export const SANDBOX_LABELS: Readonly<Record<string, string>> = Object.freeze({
    'app.kubernetes.io/managed-by': 'chamvi',
    'app.kubernetes.io/component': 'sandbox',
});

/** `SANDBOX_LABELS` as `key=value` selectors: a Docker label filter, joined by `,` a Kubernetes label selector. */
export const SANDBOX_LABEL_SELECTORS: readonly string[] = Object.freeze(Object.entries(SANDBOX_LABELS).map(([key, value]) => `${key}=${value}`));

export const RUN_LABEL = 'chamvi.dev/run';

export const WORKSPACE_LABEL = 'chamvi.dev/workspace';

/** Expiry in epoch milliseconds: a Docker label, a Kubernetes annotation. */
export const EXPIRES_AT_KEY = 'chamvi.dev/expires-at';

Rules ​

ts
function normalizeRepo(input: string, options?: NormalizeRepoOptions): string;
function assertGitRef(ref: string): void;
function resolveWorkspacePath(path: string): string;
function resolveLimits(...layers: Array<SandboxLimits | undefined>): Required<SandboxLimits>;
function parseCpu(value: string): number;
function parseMemory(value: string): number;
function assertSandboxEnv(env: Record<string, string> | undefined): void;
function prepareWorkspace(workspace: IWorkspace, input: { repo?: string; ref?: string; signal?: AbortSignal }): Promise<{ commit?: string }>;
function createOutputCollector(maxBytes: number): IOutputCollector;

normalizeRepo, assertGitRef and resolveWorkspacePath throw ValidationError (model input); resolveLimits, parseCpu, parseMemory and assertSandboxEnv throw InvalidAgentDefinitionError (configuration); prepareWorkspace throws SandboxError with git's stderr when a checkout step fails.

Remote workspaces ​

ts
function createRemoteWorkspace(input: RemoteWorkspaceInput): IWorkspace;

const TIMEOUT_KILL_AFTER_SECONDS: 5;
const EXEC_GRACE_MS: 15_000;
const ALIVE_PROBE_TIMEOUT_MS: 5_000;

A provider that can run argv in a container implements IExecTransport: it runs argv, streams stdout and stderr into the sinks it is given and resolves the exit code. createRemoteWorkspace adds the rest: the sh -c script that enters the working directory, runs the command behind the timeout wrapper and reports its exit on stderr (so an exec returns about 2 s after the command exited, even when a background job holds its output), the guard, path checks, output caps, file reads and writes, dispose. After a failed exec it asks alive() (at most ALIVE_PROBE_TIMEOUT_MS); false marks the workspace gone (disposed).

ts
/*
 * Copyright (c) 2026.
 * Author Peter Placzek (tada5hi)
 * For the full copyright and license information,
 * view the LICENSE file that was distributed with this source code.
 */

import type { IClock } from '@chamvi/core';
import type { IOutputSink } from '../output/types.ts';
import type { WorkspaceInfo } from '../workspace/types.ts';

export type TransportExecOptions = {
    stdin?: Uint8Array;
    /** Gets every stdout chunk as it arrives; the workspace caps and keeps it. */
    stdout: IOutputSink;
    /** Gets every stderr chunk as it arrives; the workspace caps and keeps it. */
    stderr: IOutputSink;
    /**
     * Aborted on a caller abort, a dispose, the client-side guard, or once the
     * workspace settled the exec itself (the command exited, a background job
     * still holds its output): stop waiting and detach.
     */
    signal: AbortSignal;
};

export type TransportExecResult = {
    exitCode: number;
};

/**
 * Runs argv in one container or pod. The remote workspace wraps argv itself
 * (the working directory, GNU `timeout`, the exit status reported on
 * stderr); a transport only executes and streams the output.
 */
export interface IExecTransport {
    /** A command that cannot start reports exit 127. */
    exec(argv: readonly string[], options: TransportExecOptions): Promise<TransportExecResult>;
    /**
     * Whether the container or pod still runs; asked after a failed exec. False
     * marks the workspace gone. A rejection, or no answer within
     * `ALIVE_PROBE_TIMEOUT_MS` (the signal then aborts), reads as alive.
     */
    alive?(signal: AbortSignal): Promise<boolean>;
    /** Deletes the container or pod; idempotent, a missing one is fine. */
    remove(): Promise<void>;
}

export type RemoteWorkspaceInput = {
    /** `randomUUID()`. */
    id: string;
    /** A mutable object the provider owns (it sets `commit` after the checkout). */
    info: WorkspaceInfo;
    transport: IExecTransport;
    /** Default `systemClock`. */
    clock?: IClock;
};

@chamvi/sandbox-local ​

ts
function createLocalSandbox(options?: LocalSandboxOptions): ISandboxProvider; // name 'local'

No isolation: development and tests only. Workspaces are folders under root (default <os tmpdir>/chamvi-workspaces); of the limits only timeoutMs applies.

ts
/*
 * Copyright (c) 2026.
 * Author Peter Placzek (tada5hi)
 * For the full copyright and license information,
 * view the LICENSE file that was distributed with this source code.
 */

import type { IClock } from '@chamvi/core';
import type { SandboxLimits, WorkspaceInfo } from '@chamvi/sandbox';

export type LocalSandboxOptions = {
    /** Directory holding one folder per workspace. Default `join(os.tmpdir(), 'chamvi-workspaces')`. */
    root?: string;
    /** Added to every workspace's environment (after the defaults, so it wins). */
    env?: Record<string, string>;
    /** The only limit local enforces: the workspace lifetime (expiry). */
    limits?: Pick<SandboxLimits, 'timeoutMs'>;
    clock?: IClock;
};

/** What `LocalWorkspace` is built from (internal). */
export type LocalWorkspaceInput = {
    id: string;
    /** The workspace folder `<root>/<id>`. */
    dir: string;
    info: WorkspaceInfo;
    env: Record<string, string>;
    clock: IClock;
    /** Called once when the workspace starts disposing. */
    onDispose: () => void;
};

@chamvi/sandbox-docker ​

ts
function createDockerSandbox(options?: DockerSandboxOptions): ISandboxProvider; // name 'docker'

const DEFAULT_NETWORK: 'bridge';
const CONTAINER_NAME_PREFIX: 'chamvi-ws-';
const SANDBOX_USER: '1000:1000';

One container per workspace, removed with its volume on dispose. Creating the provider contacts nothing.

ts
/*
 * Copyright (c) 2026.
 * Author Peter Placzek (tada5hi)
 * For the full copyright and license information,
 * view the LICENSE file that was distributed with this source code.
 */

import type { IClock } from '@chamvi/core';
import type { SandboxLimits } from '@chamvi/sandbox';
import type Dockerode from 'dockerode';

export type DockerSandboxOptions = {
    /** The Docker client; default `new Docker()` (`DOCKER_HOST` or `/var/run/docker.sock`). */
    docker?: Dockerode;
    /** Default `DEFAULT_SANDBOX_IMAGE`; pin a digest in production. */
    image?: string;
    /**
     * Default `DEFAULT_NETWORK` (`'bridge'`); `'none'` (repo-less workspaces only,
     * the clone runs inside) or any user-created network name, passed through.
     */
    network?: string;
    /**
     * `'missing'` (default) pulls once when the image is not present, `'always'`
     * before every create (a mutable tag then follows its rebuilds), `'never'` never.
     */
    pull?: 'missing' | 'always' | 'never';
    /** Added to every workspace's environment, after `SANDBOX_ENV`. */
    env?: Record<string, string>;
    limits?: SandboxLimits;
    clock?: IClock;
};

export type ContainerOptionsInput = {
    id: string;
    runId: string;
    image: string;
    network: string;
    env: Record<string, string>;
    limits: Required<SandboxLimits>;
    expiresAt: Date;
};

@chamvi/sandbox-k8s ​

ts
function createKubernetesSandbox(options: KubernetesSandboxOptions): ISandboxProvider; // name 'k8s'
function createPodClient(input: CreatePodClientInput): IPodClient;

const CONTAINER_NAME: 'sandbox';
const POD_NAME_PREFIX: 'chamvi-ws-';
const START_TIMEOUT_MS: 120_000;
const POLL_MS: 1_000;
const FATAL_WAITING_REASONS: readonly string[];

One pod per workspace. The Role needs pods create, get, list, delete and pods/exec get and create in the sandbox namespace. See Kubernetes & Flux.

ts
/*
 * Copyright (c) 2026.
 * Author Peter Placzek (tada5hi)
 * For the full copyright and license information,
 * view the LICENSE file that was distributed with this source code.
 */

import type { KubeConfig } from '@kubernetes/client-node';
import type { IClock } from '@chamvi/core';
import type { SandboxLimits } from '@chamvi/sandbox';
import type { IPodClient } from '../client/types.ts';

export type ImagePullPolicy = 'Always' | 'IfNotPresent';

export type KubernetesSandboxOptions = {
    /** Namespace of the workspace pods (required). */
    namespace: string;
    /** Default `new KubeConfig()` with `loadFromDefault()` (in-cluster or `KUBECONFIG`). */
    kubeConfig?: KubeConfig;
    /** Default `DEFAULT_SANDBOX_IMAGE`; pin a digest in production. */
    image?: string;
    /** Added to every workspace's environment, after `SANDBOX_ENV`. */
    env?: Record<string, string>;
    limits?: SandboxLimits;
    /** Extra pod labels (e.g. for a NetworkPolicy); may not name a managed label. */
    podLabels?: Record<string, string>;
    /** Default `'IfNotPresent'`; `'Always'` for a mutable tag. */
    imagePullPolicy?: ImagePullPolicy;
    /** Default `START_TIMEOUT_MS`. */
    startTimeoutMs?: number;
    /** Default `POLL_MS`. */
    pollMs?: number;
    clock?: IClock;
    /** Test seam; default `createPodClient({ kubeConfig, namespace })`. */
    client?: IPodClient;
};

export type PodInput = {
    id: string;
    runId: string;
    namespace: string;
    image: string;
    env: Record<string, string>;
    limits: Required<SandboxLimits>;
    expiresAt: Date;
    /** The `podLabels`; the managed labels win. */
    labels: Record<string, string>;
    imagePullPolicy: ImagePullPolicy;
};
ts
/*
 * Copyright (c) 2026.
 * Author Peter Placzek (tada5hi)
 * For the full copyright and license information,
 * view the LICENSE file that was distributed with this source code.
 */

import type { Writable } from 'node:stream';
import type {
    CoreV1Api,
    Exec,
    KubeConfig,
    V1Pod,
} from '@kubernetes/client-node';

export type PodExecInput = {
    argv: readonly string[];
    /** Written to the command; closed only on the `v5.channel.k8s.io` protocol. */
    stdin?: Uint8Array;
    stdout: Writable;
    stderr: Writable;
    /** Closes the socket: the command runs on until its `timeout` or the pod's deletion. */
    signal: AbortSignal;
};

/**
 * The pod operations the Kubernetes sandbox needs, in one namespace. Every
 * failure is a `SandboxError`; deleting a missing pod is fine.
 */
export interface IPodClient {
    create(pod: V1Pod): Promise<V1Pod>;
    read(name: string): Promise<V1Pod>;
    /** Without a grace period; a missing pod is fine. */
    delete(name: string): Promise<void>;
    list(labelSelector: string): Promise<V1Pod[]>;
    /** Runs argv in the pod's sandbox container; the exit code from the exec status. */
    exec(name: string, input: PodExecInput): Promise<{ exitCode: number }>;
}

export type CreatePodClientInput = {
    kubeConfig: KubeConfig;
    namespace: string;
};

export type PodClientParts = {
    core: Pick<CoreV1Api, 'createNamespacedPod' | 'readNamespacedPod' | 'deleteNamespacedPod' | 'listNamespacedPod'>;
    exec: Pick<Exec, 'exec'>;
    namespace: string;
};

/** The part of the exec WebSocket the client uses. */
export type ExecSocket = {
    readonly protocol: string;
    close(): void;
    on(event: 'close', listener: () => void): unknown;
    on(event: 'error', listener: (error: Error) => void): unknown;
};