builtin:container:
MicroVM builds
With both hooks set tobuiltin:vm, each build runs in a Firecracker
microVM with its own kernel. The host must be x86_64 Linux, with Docker
Engine 26 or later and Docker Buildx 0.18 or later. The runner’s account
must be able to open /dev/kvm. runner init names
builtin:vm on such a host; the full rule is in
outerlayer runner init.
outerlayer doctor fails builtin:vm hooks on any other host, and says
why.
The microVM is made from the recipe’s image, with the same tunnel and tokens
a container build has. The tunnel, the destinations, the branch rule and the
failure reasons below apply to it too, except disk_limit_exceeded and
disk_check_failed. Its
build limits work differently: see The build block. It has no network device: its connections leave over vsock to the
runner’s tunnel. A recipe that adds the docker-in-docker feature gets its
own Docker daemon inside the microVM. That is how a build that needs Docker
runs.
Containers and docker build steps reach public hosts through the tunnel
too. Provision writes the build user’s Docker client configuration. The
Docker CLI and Compose use it to give each container and each
docker build step proxy variables. The variables name the tunnel at
http://172.17.0.1:3128. The same allowHosts list and refusals apply. The
tunnel answers 403 to a host the list omits, and each host a container
reaches is among the hosts reached. A direct connection from a container
fails, because the microVM has no network device.
Three limits apply:
- A container that calls another container by service name over HTTP must
list that name in
NO_PROXY. The defaults skip the proxy forlocalhost,127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12and192.168.0.0/16only. - A tool that ignores proxy variables needs its own proxy setting. Node’s
fetchis one: setNODE_USE_ENV_PROXY=1in the container. - A container that you start through the Docker API, not the Docker CLI or Compose, gets no proxy variables. The Supabase CLI and Testcontainers do this.
What a build gets
The runner builds the repository’s image, asouterlayer runner image
does, and starts one container from it. The container has no network but
loopback, a read-only root filesystem, and one volume for the clone. Your
home directory, other builds and the Docker socket are out of reach. The
runner mounts no folder of its own into the container: the build’s files are
copied into /outerlayer/job through docker exec.
Commands run as the recipe’s remoteUser, else as the image’s own user.
When that is root, or neither names one, they run as ol-agent. The name
ol-relay and the user and group id 60100 belong to the relay that carries
the build’s connections to the runner. A recipe image that already uses
that id fails the image build, and a remoteUser named ol-relay or
with that id fails the build.
A lifecycle command that exits non-zero fails the build at provision,
with a reason naming the command.
The repositories in a build
A container or microVM build checks out every repository connected to the factory, each on the item’s branch, so one session can change several. The item’s own repository is the primary. In a build with more than one repository, every repository is at/build/work/repos/<owner>/<repo>, with
the owner and repository written in lower case, such as
/build/work/repos/acme/web for Acme/Web. The primary is one of them, and is also at
/build/work/checkout. A factory with one repository gets one checkout at
/build/work/checkout and no repos directory.
In each repository the build starts on the branch the claim named there. That
is the head branch of the item’s open pull request in that repository. When
there is no such pull request, it is outerlayer/<factory>/<item number>. See The branch a claim
names.
OUTERLAYER_REPOSITORIES is a JSON list of the checkouts. Each entry has
repository, path, branch and primary:
outerlayer work open-pr --repository <owner/name>.
The primary decides where the agent starts. The governed context is delivered
to a directory outside its checkout, and GH_REPO and OUTERLAYER_BRANCH name
the checkout. gh holds a
read token for the primary only. Each repository’s push token reaches the
build only through the runner, which limits it to that repository.
The image and the setup commands do not come from the primary. They come from
the repository that governs the build: the control plane, or the repository
itself when it governs itself. The setup commands run once, in that
repository’s checkout. See Build a repository’s image from its devcontainer
file.
Host checks across repositories
After a build, the runner runs the checks a control plane declares withwhere: host in a new container or microVM started from the build’s image.
Every repository of the claim is checked out there at the same fixed paths as
in the build. Each is checked out at the branch the build pushed there, or at
the repository’s default branch where it pushed none. Host checks therefore run with
every pushed branch checked out, and a failing test in any of them fails the
item.
The setup commands run first, read at the commit the build’s instructions came
from. A pull request that edits the control plane’s devcontainer changes
nothing until it merges. A script a setup command calls is read from the
checkout, which can be a build’s branch.
A build that pushed no branch in any repository fails every check, with a
reason that says the build pushed no branch.
A runner older than protocol 16 cannot build in more than one repository. A
factory with more than one connected repository refuses its claim with
runner_protocol_too_old, and the item stays available to a newer runner. See
Runner protocol. Builds that run your
own hooks, and builtin:process, work in one repository.
The build block
A microVM build uses the first four keys in its own way:
memoryis the microVM’s memory. Under the service thatouterlayer runner install --servicewrites, the host also limits the microVM tomemoryplus 1 GiB, with no swap. See Memory limits.cpusis the number of virtual CPUs the microVM gets. Give a whole number. Any other value stops the microVM from starting, and the build fails at provision.pidsdoes not apply. The runner sets no process limit in a microVM build.diskis the size of the microVM’s build disk, a sparse file on the host. The disk holds/build,/tmpand the data of the build’s Docker daemon, if it has one. A write past it fails with “No space left on device”. The runner does not measure the disk, so it does not end the build withdisk_limit_exceededordisk_check_failed.
variables is the only way a host variable enters a build. Claude’s
credential reaches it only as a placeholder. Repository tokens (GH_TOKEN,
GITHUB_TOKEN, GH_ENTERPRISE_TOKEN, GITHUB_ENTERPRISE_TOKEN) are never
passed; the runner logs each name it skipped. A name the runner sets itself,
such as HOME, PATH or a proxy variable, is refused.
To size the host for these limits, see
Memory limits.
The gateway and Claude
Every build the runner provisions with a built-in hook (builtin:vm,
builtin:container or builtin:process) gets two built-in
destinations. The build holds neither the item
key nor Claude’s credential.
The runner reads
CLAUDE_CODE_OAUTH_TOKEN (from claude setup-token), or
else ANTHROPIC_API_KEY, from its own environment; restart it after a change.
A terminal claude login is not forwarded. With neither set, each build fails
at provision as agent_credential_missing. See
Give the runner Claude’s credential.
A build your own hooks run gets no built-ins. It holds the item key as
OUTERLAYER_API_KEY; see The build’s variables.
The tunnel
The container’s only way out is a tunnel the runner serves for that build. A container build reaches the runner throughdocker exec, so the tunnel is
not a socket in the build’s directory. The runner sets the standard proxy variables to it, http://127.0.0.1:3128.
A tool that ignores them cannot connect; set pip’s --proxy or Maven’s
settings.xml to that address in the recipe.
Not every tool reads those variables. A tool’s own documentation says where
it takes its proxy from. A tool that ignores them fails with a connection or
name-resolution error, because the build has no other way out. A host the
tunnel refuses gets a 403 instead. So a download that fails without a 403
usually means a tool that needs its own proxy setting.
A task runner that filters the environment hides the proxy variables from
every task it starts. Turborepo does this by default. List HTTP_PROXY,
HTTPS_PROXY, NO_PROXY, their lowercase forms and NODE_USE_ENV_PROXY in
globalPassThroughEnv in turbo.json, or a task that downloads anything,
such as a Next.js build that fetches Google Fonts, cannot connect.
- the host’s own addresses
- private and loopback addresses
- link-local addresses, including the cloud metadata address
169.254.169.254 - Tailscale addresses
- the Azure wire server
168.63.129.16 - the ranges
192.0.0.0/24and198.18.0.0/15 - NAT64 addresses
- 6to4 addresses (
2002::/16) that carry a refused IPv4 address
403 naming the range. Listing such a
host in allowHosts does not change that. To reach a private service, make it
a destination.
Limiting the hosts a build reaches
*.githubusercontent.com, which matches names under the domain but not the
domain itself. An empty list refuses every public host. An unlisted name gets
a 403 with the range not-allowed. The list does not limit the gateway,
Claude, your destinations or the git remote. It does not limit the image
build either, which runs outside the tunnel. See
What an image build reaches.
To find the hosts your builds need, run a few without the list, then run
outerlayer doctor on the host. While the list is unset, its “Build hosts”
check warns and lists every host past builds reached, ready to paste into
allowHosts. Review it first: a host a build should never have reached is
in it too. Each build’s own list is hosts in its environment (see
What a build records).
The git remote and the build’s tokens
A container build holds no credential that can write to its repository. Git andgh reach GitHub through the runner, which adds short-lived read and push
tokens the gateway issues
(GitHub App permissions and build tokens).
gh can read, such as gh pr view, but not write.
The branch rule. A push may update only the claim’s branch in the
repository it goes to. A push to a repository the claim does not name is
refused before it reaches GitHub. On an
outerlayer/… branch any update goes through, force-pushes included;
elsewhere only creating the branch and fast-forwards. A refused push updates
no ref, the runner logs push refused: <refs>, and git prints:
A governed repository’s context
When a control-plane repository governs the item’s repository, a container build receives the control plane’s instructions, skills, agents and hooks. A governed build reads its control plane from a directory outside the checkout. Nothing from the control plane is written into the checkout, so a commit cannot pick it up. The runner fetches the control plane on the host and copies it, write-protected, to~/.outerlayer/context/<owner>/<repo>/<commit>/ in the
build user’s home. The
directory holds the control plane’s AGENTS.md, .claude/ and .outerlayer/,
the maintained skills, a CLAUDE.md and a settings.json. It also holds the
control plane’s .agents/ folder, and each skill in .agents/skills/ is
delivered under .claude/skills/ too. Paths that start
with .outerlayer/ or .claude/ in the delivered CLAUDE.md, skills and
agents point into that directory.
In a governed build, claude runs outerlayer claude. The runner puts a
claude program ahead of the image’s Claude Code on the command’s PATH.
That program starts Claude Code with the directory attached. The main session
and its subagents then follow the control plane. A configured command needs no change.
A command such as claude -p "Build the issue" works as it is.
Every build puts that claude program first, including a build of a
repository that governs itself. There it starts the image’s Claude Code with
the arguments alone, asks the gateway nothing, and does not check the Claude
Code version. It adds the checkout’s own .agents/skills/ skills, so Claude
Code loads them under their own names.
The release records the control-plane repository and the commit that was
delivered. The work item then shows which version of the shared rules a build
ran under.
The build is refused before the agent starts in two cases:
- The repository has a skill in
.claude/skills/or.agents/skills/with the same name as a shared skill, so a repository could replace a shared one. Rename or remove it. - The image’s Claude Code is older than 2.1.284. Rebuild the image.
context_layout_unsupported below.
The runner reads the control plane through the GitHub App. The gateway issues
a read-only token for that one repository, and the runner uses it for the
fetch. The token is never written to a file or a log, and the build never sees
it. The App’s installation must include the control plane.
A host git login is needed only without a GitHub App. A gateway that has none
issues no token, so the runner reads the control plane with the host’s git
login. The host also needs a git login for outerlayer runner image on a
private repository. outerlayer doctor checks all of this as
Control plane access.
Failure reasons
A read token that fails for a temporary reason, such as
github_unavailable,
releases the claim with no outcome; the runner retries on its next poll. A
refused push token fails the push, and the build fails at its next health
check.
Stopping a build
Stopping a build stops its container. Cleanup removes the container and volume; a runner that crashed finishes it on restart. A container build keeps running when its runner restarts or crashes. Its command runs inside the container and keeps its output and exit code in the build’s volume. When the runner starts again, it picks the build up. If the command ended while no runner was there, the build ends with the exit code the volume holds, or asinterrupted when it holds none. The item can then be built again.
A build whose runner never comes back stops when its lease expires. The runner
tells the container when the lease ends, and the container stops itself at that
time. If the runner logs
cleanup failed, its next housekeeping
pass removes what the build left, within the hour. To free the space
sooner, remove what it names with docker rm --force and
docker volume rm.
What a build records
outerlayer work status --json shows each build’s environment under
claims: the isolation, image, hosts reached and refused, variable and
destination names, and CLI versions. It never holds a secret.