Workspaces & Coding
An agent that reproduces a bug, runs tests or fixes code needs a place to run commands. chamvi gives each run disposable workspaces: a directory, optionally a shallow checkout of a git repository, in which the model runs commands and edits files through tools. Workspaces come from a sandbox provider and are held by a workspace manager that disposes them when the run settles.
npm install @chamvi/sandbox @chamvi/coding @chamvi/sandbox-dockerProviders
| Provider | Package | A workspace is | Isolation |
|---|---|---|---|
| local | @chamvi/sandbox-local | a temporary directory on the host | none: development and tests only |
| Docker | @chamvi/sandbox-docker | one container | uid 1000, read-only root, no capabilities, no-new-privileges, cpu, memory and pid limits |
| Kubernetes | @chamvi/sandbox-k8s | one pod | non-root, read-only root, no capabilities, no service account token, resource limits, activeDeadlineSeconds |
All three pass the same workspace contract (describeWorkspaceContract from @chamvi/test-kit): argv-only exec, path checks, output caps, process-group kills on timeout, and dispose semantics.
const provider = createDockerSandbox({
// pin a digest in production
image: 'ghcr.io/chamvi/sandbox-node:24',
// a bridge without inter-container traffic, firewalled from the host
network: 'chamvi-sandbox',
limits: {
cpu: '2',
memory: '4Gi',
pids: 1024,
},
});// in-cluster service account or KUBECONFIG
const provider = createKubernetesSandbox({
namespace: 'agents-sandbox',
podLabels: { 'example.com/egress': 'restricted' },
limits: {
cpu: '2',
memory: '4Gi',
storage: '8Gi',
},
});The container providers use ghcr.io/chamvi/sandbox-node:24 by default (Node.js 24, npm, git, tini, GNU coreutils), built for linux/amd64 and linux/arm64. Pin a digest in production. See Docker Image.
The manager and its tools
const workspaces = createWorkspaceManager({
provider,
// only your own repositories; the model cannot clone anything else
allowRepo: (url) => url.startsWith('https://github.com/acme/'),
limits: { timeoutMs: 20 * 60_000 },
});
const repro = defineAgent({
name: 'repro',
model,
instructions: 'Check out the repository from the payload, write a failing test for the reported bug, ' +
'run it and answer with the output and the diff.',
triggers: [http()],
tools: { ...workspaces.tools(), ...codingTools(workspaces) },
approvals: { bash: 'user' },
});
const engine = new Engine({
store,
agents: [repro],
resumeSecret: 'change-me',
});
// a run's workspaces go once it completed, failed or was cancelled
workspaces.attach(engine);
await engine.start();
process.once('SIGTERM', async () => {
await engine.drain({ timeoutMs: 25_000 });
// after the drain: runs still executing keep their workspaces until then
await workspaces.close();
process.exit(0);
});workspaces.tools() gives the model two tools; codingTools(workspaces) adds seven more. Every tool resolves its workspace with the calling run's id, so a run can only reach its own workspaces.
| Tool | Package | What it does |
|---|---|---|
create_workspace | sandbox | { repo?, ref? }: a shallow checkout of repo at ref, or an empty git repository; the same repository again returns the same workspace (reused: true) |
dispose_workspace | sandbox | { workspace? }: disposes one workspace |
read_file | coding | A line range of a text file (at most 2000 lines, 65 536 characters) |
write_file | coding | Replaces a file, creating directories |
edit_file | coding | Replaces an exact string, once (or every match with replaceAll); edits and writes of one file run one after the other, so several in one step all apply |
grep | coding | git grep -E over tracked and untracked files |
list_files | coding | Tracked and untracked files, git-ignored ones skipped |
bash | coding | sh -c <command> inside the workspace, exit code and capped output |
git_diff | coding | git status --short and the unified diff |
The coding tools take an optional workspace id; omitted, they use the run's only workspace. The model sees every workspace at /workspace, whatever the provider maps it to. See the @chamvi/coding reference for inputs and results.
A run may hold maxWorkspacesPerRun (3) workspaces at once. Repositories are owner/name (GitHub) or an https: URL without credentials, query or fragment; allowRepo narrows them, allowFileRepos adds file:///absolute/path for development. Refs are plain branch, tag or commit names that git can never read as an option or a range.
Lifecycle
A workspace is keyed by run and repository. It lives until the first of:
- the run settles:
completed,failedorcancelled(throughworkspaces.attach(engine)). A runwaitingfor an approval orinterruptedon this replica keeps its workspaces. dispose_workspace, orworkspaces.release(runId, id?)- its expiry,
limits.timeoutMs(default 30 minutes), checked on every use - it was found gone: its container stopped or was removed, its pod failed, was evicted or deleted, or its local directory was removed. The call that found it says "workspace ... is gone; call create_workspace for a new one", and
create_workspacethen creates a new one workspaces.close(): call it afterengine.drain()at shutdown. It aborts the creates still in flight and waits for them and for every disposal, at most 30 seconds (close({ timeoutMs })), then reports what is left toonError- garbage collection:
acquirerunsworkspaces.collectGarbage()at most every 10 minutes, which also asks the provider to remove expired containers, pods or directories left behind by crashed processes
A workspace lives in one process. A run that resumes on another replica, or after a restart, finds none and is told to call create_workspace again; the model redoes the checkout. Re-attaching to a surviving container or pod by label is planned.
For development, the local provider runs everything in a temporary directory:
// no isolation: commands run as your user in a temporary directory
const workspaces = createWorkspaceManager({
provider: createLocalSandbox(),
// local fixtures as file:///absolute/path repositories
allowFileRepos: true,
});Commands, output and timeouts
exectakes argv only; the only shell is thebashtool'ssh -cinside the workspace.- stdout and stderr are capped at 64 KiB each (
stdoutTruncated,stderrTruncated); output past the cap is drained, never buffered, soyescannot block the child or fill the host's memory. - A command times out after
timeoutMs(default 5 minutes, at most 30, never past the workspace's expiry) withtimedOut: trueas a result, not an error. The whole process group is killed, background jobs included: local sendsSIGKILLto the group, Docker and Kubernetes run every command inside GNUtimeout -k 5. A command that exits on its own returns with its exit code even when a background job it started (node server.js & echo started) holds its output (after about 2 seconds); the job runs on until the workspace is disposed, so the next call can probe the server. - A
cwdthat is no directory in the workspace rejects with aSandboxErroron every provider. - Abort (a cancelled run, a run timeout): local kills the process group. Docker and Kubernetes detach: the client stops waiting, and the command runs on until its
timeoutfires or the workspace is disposed, which the settled run'sattachdoes right after. - A sandbox failure (a failed create or checkout, an unknown or expired workspace, a daemon or API error) is a
SandboxError: a tool error the model sees, never a failed run.
Why the coding tools are not effect tools
write_file, edit_file, bash, create_workspace and dispose_workspace change state, yet they are plain defineTool() tools, not effect tools. An effect record lives in the store and survives a crash; a workspace lives in one process and does not. After a crash the redone step would run on a fresh workspace, where a recorded "applied" result would skip the write and leave the workspace and the history out of step. Effect tools would also merge two identical calls of one step (npm test twice) into one.
Re-applying is safe instead: write_file is idempotent, edit_file fails with "oldString not found" when it already applied (the model sees that), and bash runs in a disposable sandbox. Nothing outside the workspace changes; pushing is not part of these tools.
Security model
- The model runs untrusted code. Whatever it reads (an issue body, a README, a test's output) can steer what it runs: prompt injection. Run untrusted repositories on Docker or Kubernetes, never local, narrow
allowRepo, and putbashbehind an approval (approvals: { bash: 'user' }) where a human should see each command. - Local isolates nothing. Commands run as the host user with your files and network in reach. It only keeps the host environment out (just
PATH, its ownHOMEandTMPDIR) and kills process groups. - Docker and Kubernetes enforce a non-root user, a read-only root filesystem, no capabilities, no privilege escalation, cpu and memory limits (pids on Docker, the kubelet's
podPidsLimiton Kubernetes; storage on Kubernetes), a lifetime, and no host or chamvi environment. Kubernetes mounts no service account token. - No credentials enter a workspace. URLs with credentials are refused (and never echoed in an error), git's credential helpers are reset for the fetch, and only
SANDBOX_ENVplus your configuredenvreach a command. Private repositories are not supported yet. - A sandbox is a code-execution boundary, not a network boundary. On a Docker bridge a workspace reaches the host gateway (the chamvi API, Postgres, an MCP server), other containers and cloud metadata (
169.254.169.254); on Kubernetes every service in the cluster. Until the Helm chart's NetworkPolicy ships, add one yourself (selectapp.kubernetes.io/component=sandbox, allow only GitHub and the npm registry, deny the chamvi namespace and the metadata address), or on Docker a network withenable_icc=falseplus host firewall rules. - Docker socket access is root on the host. Give chamvi a socket proxy or rootless Docker rather than the raw socket.