Skip to main content

Agent sandbox

Overview​

Each hosted agent run works in its own microVM. The sandbox host (apps/sandbox-host) starts these microVMs. The server does not start them. The server calls the sandbox host over HTTP.

This page is for the operator of a Vantik instance. It tells how to bring up the sandbox host, and how to find a fault. To start a run, read Delegate an issue to an agent. Only a person starts a run, with the delegate control on an issue.

How the parts connect​

  • The server sends the files of the run to the sandbox host: the checkout, the prompt, and the context of the issue. Then it runs each command in the guest through the sandbox host.
  • The guest has no direct network. The sandbox host sends each request of the guest, and it refuses each host that is not on the allowlist of the run.
  • The server fetches the repository and pushes the branch. The guest never connects to the git host.

Why the sandbox host is a separate process​

A microVM needs QEMU and hardware virtualisation: KVM on Linux, or HVF on macOS. The server often runs in a container that has neither. On macOS, no container gets hardware virtualisation. So the sandbox host runs where the hypervisor is, and the server calls it.

If the server cannot reach a sandbox host, the server refuses each new run. The delegate control on the issue shows the reason. The server never uses a weaker sandbox in place of a microVM.

Requirements​

  • QEMU. The sandbox host needs qemu-img, and qemu-system-aarch64 or qemu-system-x86_64 for the architecture of the machine.
  • Hardware virtualisation. Without it, QEMU emulates the processor. The runs then work, but they are very slow.
  • For a native sandbox host: a checkout of the repository, Node.js 23.6 or later, and pnpm 10. The container image has Node.js 24, and it needs none of these.
  • Memory and disk for each run. Each guest gets 4096 MB of memory and 2 CPUs. Its disk can grow to 30720 MB with the review, or 20480 MB without it. The disk is sparse, so a run uses only the space that it writes.
  • To build the guest image: macOS, with podman or Docker. Only a native sandbox host can use the guest image.

Bring up the sandbox host​

1. Make the token​

The server and the sandbox host must have the same token. The sandbox host does not start if the token has fewer than 24 characters.

echo "SANDBOX_HOST_TOKEN=$(openssl rand -hex 32)" >> .env

2. On macOS, run it natively​

Run each command in this section from the root of the checkout.

  1. Install QEMU and the dependencies of the workspace.
brew install qemu
pnpm install
  1. Build the guest image. This step takes a few minutes. Read The guest image.
pnpm --filter sandbox-host build:guest
  1. Start the sandbox host in a terminal. Keep the terminal open.
pnpm sandbox-host

The sandbox host reads the .env file at the root of the repository. A variable that the environment already sets wins over the file. The sandbox host listens on 127.0.0.1:3004.

The server container reaches it at http://host.docker.internal:3004, which is the default in docker-compose.yaml. If you run the server with pnpm dev, add the local address to .env:

echo "SANDBOX_HOST_URL=http://127.0.0.1:3004" >> .env
  1. Make the server read the new values. If the server runs in a container, create the container again:
docker compose up -d --no-deps --force-recreate server

If you run the server with pnpm dev, stop it and start it again.

3. On Linux, run the container​

  1. Tell the server container the address of the sandbox host.
echo "SANDBOX_HOST_CONTAINER_URL=http://sandbox-host:3004" >> .env
  1. Start the sandbox host with the sandbox compose profile. The command also creates the server container again, so that the server reads the new values.
docker compose --profile sandbox up -d --no-deps --force-recreate server sandbox-host

The container needs /dev/kvm. The entrypoint starts as root only to find the group that owns /dev/kvm. Then it runs the sandbox host as the node user, with that group. The container publishes no port. Only the server, on the compose network, calls it.

The container image does not include the Vantik guest image. So the container uses the stock image of Gondolin, the library that starts the microVMs. The first run downloads that image into the sandbox-images volume, so that run takes longer. With the stock image, each run keeps its checkout in memory. Read Without the guest image.

This page gives no method for a native sandbox host on Linux. On Linux, the build of the guest image must run as root, because it uses chroot. The image then goes to the image store of root. Also, with the defaults, the server container cannot reach a native sandbox host on Linux.

4. Check the sandbox host​

  1. Look at the first log lines of the sandbox host. The line Listening on <address>:<port> has "available":true when the sandbox host can start a microVM. If available is false, the line also has a reason. Each log line is a JSON object.
  2. Look for a warning about the guest image. If it shows, read The guest image.
  3. Open an issue in Vantik with a description of 40 characters or more. The delegate control must show no reason. A run also needs a module with a repository. Read Delegate an issue to an agent.

Configuration​

The Read by column tells which part reads each setting. Each one goes in .env.

VariableRead byDefaultWhat it does
SANDBOX_HOST_TOKENServer and sandbox hostnot setThe shared secret. The server sends it as a bearer token. The sandbox host refuses to start without it, or with fewer than 24 characters.
SANDBOX_HOST_URLServernot setThe address of the sandbox host. Without it, the server refuses each run. In the compose file, the server container gets this value from SANDBOX_HOST_CONTAINER_URL.
SANDBOX_HOST_CONTAINER_URLdocker-compose.yamlhttp://host.docker.internal:3004The value of SANDBOX_HOST_URL in the server container. With the sandbox profile, set http://sandbox-host:3004.
SANDBOX_HOST_PORTSandbox host3004The port of the sandbox host.
SANDBOX_HOST_BINDSandbox host127.0.0.1The address of the sandbox host. The container image sets 0.0.0.0. On a workstation, keep the loopback address, so only the local server can connect.
SANDBOX_HOST_IDLE_MSSandbox host300000 (5 minutes)The sandbox host disposes of a sandbox that gets no request in this time. The server sends a keepalive each minute while it holds a sandbox. So only the sandbox of a stopped server gets to this limit.
SANDBOX_HOST_GRACE_MSSandbox host120000 (2 minutes)The time after the time limit of a run before the sandbox host disposes of its sandbox.
SANDBOX_HOST_MAX_BODY_MBSandbox host512The largest request body that the sandbox host accepts, in MB. The server sends the checkout of the repository in one request.
GONDOLIN_VMMGondolin, in the sandbox hostqemuThe virtual machine monitor: qemu or krun. Keep qemu. Read The virtual machine monitor.

The sandbox host does a check of its sandboxes each 15 seconds. When it stops on SIGINT or SIGTERM, it disposes of each sandbox.

The server also has settings for the lease, the attempts, and the concurrency of agent runs. Read the Environment reference.

The guest image​

The command pnpm --filter sandbox-host build:guest builds the Vantik guest image. It tags the image vantik-guest:latest in the local image store of Gondolin. The default store is ~/.cache/gondolin/images. On macOS, Gondolin builds the filesystem in a container, so podman or Docker must run.

The sandbox host looks for the image each time that it starts a sandbox. A new image thus applies from the next run, and you do not restart the sandbox host. If the image is not there, the sandbox host logs a warning when it starts.

Without the guest image​

The stock image has a root filesystem of about 260 MB, with about 80 MB free. That filesystem cannot grow. So without the Vantik image, the sandbox host puts a tmpfs on /workspace. A tmpfs is memory. Its limit is three quarters of the memory of the guest, so 3072 MB.

The checkout, its dependencies, and the harness then share the memory that the commands of the agent use. A repository with a large node_modules fills most of it. The guest then uses its time to get memory back, not to run the tests. The run then looks like a model that stopped. Build the guest image to stop this fault.

With the Vantik image, the root disk grows to the disk limit of the run, and /workspace is a directory on that disk. The image adds resize2fs for this purpose.

What the image holds​

  • Alpine Linux 3.23, with the linux-virt kernel.
  • bash, curl, git, openssh, Node.js, npm, Python 3 and uv.
  • The code tools of the agent: ripgrep, ast-grep, fd and jq.
  • The Pi harness, at the version that the server pins. The image writes that version to /opt/vantik/pi-version.
  • TypeScript 5.9.3, typescript-language-server and pyright.

When the image is current, a run fetches nothing at run time for the harness.

When to build it again​

Build the image again after an upgrade that changes the Pi version, or after a change to apps/sandbox-host/guest/build-config.json. A run compares the version in the image with the version that the server pins. If they are different, each pass of the run fetches Pi from registry.npmjs.org. That run is slower, but its result is correct.

The virtual machine monitor​

GONDOLIN_VMM selects the backend of Gondolin. The default is qemu. The value krun selects libkrun, which Gondolin calls experimental. It also needs the krun runner of Gondolin. The sandbox host does a check for the QEMU programs only when the backend is qemu.

Keep qemu. In the tests of this project, krun did not make a run faster.

Isolation and credentials​

  • One microVM for each run. If the sandbox host cannot start a microVM, the run fails. Vantik never uses a container in its place.
  • The model key. The guest gets a placeholder in the variable that the SDK of the provider reads, for example ANTHROPIC_API_KEY. The sandbox host puts the real key in the requests to the host of the provider, and to no other host. If the agent prints its environment, it prints only the placeholder.
  • Where the key comes from. A run uses only the keys of the workspace, from Settings → Agents. It never uses a key from the environment of the server. Read Model access.
  • No git token in the guest. The server fetches the repository, and sends it to the guest as an archive. After the work, the server reads the tree back. Then the server pushes the branch. If the git host has pull requests, the server also opens one.
  • No Vantik token in the guest. The Vantik tools in the guest write lines to an outbox file. After each pass, the server reads the file and checks each line. Then it applies the line as the agent of the run. A note becomes a comment on the issue, and a fact becomes a proposed knowledge entry. The server ticks a criterion only when the run succeeds.
  • The egress allowlist. A guest can reach the host of its model provider, registry.npmjs.org, and the hosts that its modules declare. The git host is not on the list. The sandbox host refuses each other host, and Vantik counts each refused request on the run. A sudden increase can show a prompt injection.

A module declares its hosts in the How to verify the work section of its page. Read How to verify the work.

Executors​

An executor is the backend that does the work of a run. Vantik has one executor, hosted, with the label Vantik hosted sandbox. It runs the review cycle in a sandbox from the sandbox host. Read The review cycle.

The server registers the executors when it starts. A run that names no executor uses the only executor that the server registered, so you set nothing. Vantik has no runner on another machine that asks the server for work.

The hosted executor is available to a workspace when two conditions are true:

  • The server can reach a sandbox host, and that sandbox host can start a microVM.
  • The workspace has a model key in Settings → Agents.

How Vantik measures the spend​

Two sources give the cost of each model call:

  • The harness. Pi reports the cost of each message, from the token counts and the prices in its catalog.
  • The egress meter. The sandbox host reads the usage of each response from the provider, as the bytes go to the guest. A gateway, for example OpenRouter, also reports the cost that it bills. The guest cannot change this record.

The server joins the two sources on the response ID. A call uses the billed cost when the provider reported one, and the catalog price if not. A metered call that the harness did not record, for example a cut stream, still adds its billed cost.

The page of the run shows the spend while the run works. While a pass works, the server writes it a maximum of one time in 3 seconds. When a pass ends, the server writes it immediately. The review cycle compares the spend with Spend per issue (USD) in Settings → Agents between passes. The default is $5. Read The budget.

The same figures go to your telemetry backend. Each model call is a chat <model> span with gen_ai.usage.cost. The attribute vantik.llm.cost_source tells if the cost is provider or catalog. Read The telemetry contract.

How to find a fault​

Compose reads .env only when it creates a container. A restart of a container does not read it again. So to apply a change to .env, create the container again with docker compose up -d --no-deps --force-recreate <service>. For a native process, stop it and start it again.

What you seeCauseWhat to do
The delegate control says "No sandbox host is configured".The server has no SANDBOX_HOST_URL or no SANDBOX_HOST_TOKEN.Set both for the server. Then create the server container again.
The delegate control says "The sandbox host at <url> did not answer".The sandbox host does not run, or the address is wrong.Start the sandbox host. In a container, 127.0.0.1 is the container itself, so use host.docker.internal or the name of the service.
The delegate control says "The sandbox host refused the server's token".The two tokens are different.Set the same SANDBOX_HOST_TOKEN for both. Then apply the change to both.
The sandbox host stops when it starts, with a message about SANDBOX_HOST_TOKEN.The token is not set, or it has fewer than 24 characters.Make a token as in Make the token.
The reason says that the sandbox host has no qemu-img or qemu-system-* on its PATH.QEMU is not installed for the process of the sandbox host.Install QEMU on the machine of the native sandbox host. Then stop the sandbox host and start it again.
The container logs "No /dev/kvm in this container".The container has no /dev/kvm, so QEMU emulates the processor.Map /dev/kvm into the container, as docker-compose.yaml does. On macOS, run the sandbox host natively.
Compose does not create the sandbox-host container, with an error about /dev/kvm.The machine has no /dev/kvm.Turn on KVM on the machine, or run the sandbox host on a machine with KVM.
A run on a large repository becomes very slow, and the model looks stopped.The sandbox host has no guest image, so the checkout is in memory.On macOS, build the guest image. The next run uses it. The container cannot use the guest image.
After an upgrade, each pass of a run starts slowly.The guest image has a different Pi version from the server, so each pass fetches Pi with npx.Build the guest image again.
A run fails, and the reason says that the request body is larger than a number of bytes.The checkout is larger than SANDBOX_HOST_MAX_BODY_MB.Increase SANDBOX_HOST_MAX_BODY_MB for the sandbox host. Then apply the change to the sandbox host.
A setup command cannot get its packages.The host of the package registry is not on the allowlist.Add the host in How to verify the work on the page of the module.
A run starts a new attempt after the server stopped during the run.The lease of the run expired. The lease sweep starts the next attempt, to a maximum of AGENT_RUN_MAX_ATTEMPTS.Nothing. Read the Environment reference for the lease settings.
The first pass fails with a message from the provider, for example "invalid API key".The provider refused the key or the model.Correct the key or the model in Settings → Agents.
The telemetry backend shows no chat <model> spans or no costs.The backend dropped the batches, for example at its memory limit.Look at the logs of the backend first.

After a server stops during a run, its sandbox stays until SANDBOX_HOST_IDLE_MS ends. When the server starts again, it disposes of the sandboxes of runs that ended.