Sandboxes
The sandbox packages: @chamvi/sandbox (contracts, rules, the workspace manager) and the three providers. See Workspaces & Coding for the behaviour.
@chamvi/sandbox
npm install @chamvi/sandbox @chamvi/core ai --save@chamvi/core and ai are peer dependencies.
createWorkspaceManager
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.
/*
* 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
/*
* 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>;
}/*
* 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>;
}/*
* 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',
});/*
* 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
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
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).
/*
* 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
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.
/*
* 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
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.
/*
* 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
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.
/*
* 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;
};/*
* 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;
};