Sandbox images

With the Apple container and Docker drivers, each session runs in a container made from an image. This page describes the default image and how hosts get it, how a project uses an image of its own or builds one from its repository, which images a host runs, and what an image needs.

The default image

ghcr.io/hyperstructure/sandbox is built from hyperstructure/sandbox, a public repository whose README lists the versions: Debian bookworm with git, the GitHub CLI, build tools, Python and uv, Rust, Node.js, TypeScript, Bun, Claude Code and fctl. It is published for arm64 (Apple silicon) and amd64.

Tag Image
0.5.0 fctl 0.5.0's. Hosts of that version use it.
0.5 The newest 0.5 release's.
latest The newest release's.

The sandbox repository's Image workflow builds every tag from its main branch, with that release of fctl and the newest Claude Code. It looks for new fctl releases every hour, so a release's image follows within the hour. fctl turns off Claude Code's auto-updater, so an image's Claude Code is as old as the image: every day, the workflow rebuilds the latest release's image when Claude Code has a newer release, and once the image is a week old, for Debian's updates. A change to the image rebuilds it too. A rebuild moves the tags; to pin one build, add its digest: ghcr.io/hyperstructure/sandbox:0.5.0@sha256:….

Its labels say what is inside:

Label Value
org.opencontainers.image.version, sh.fctl.version fctl's version
sh.fctl.claude-code.version Claude Code's version
org.opencontainers.image.revision the commit of hyperstructure/sandbox it was built from; images built elsewhere have none
org.opencontainers.image.created when it was built
docker image inspect ghcr.io/hyperstructure/sandbox:0.5.0 \
  --format '{{ index .Config.Labels "sh.fctl.claude-code.version" }}'

How hosts get it

A host whose host.toml sets no image uses the default image of its own version, so upgrading the host (fctl self update) moves its new sessions to the new version's image. The host pulls the image in the background when it starts, and a session that starts before it has the image pulls it itself. When the image can't be pulled, as in the hour after a release before its image is published, the host pulls latest instead, and sessions start from it and say so in their log. The first session after the version's image is published pulls it.

The host pulls its tag again when it starts and every 6 hours, so new sessions start from the newest build of it, with the newest Claude Code. Sessions don't wait for that: they start from the build that is there, and a tag that hasn't changed costs one request to ghcr.io. fctl host image pull pulls it now. While the version's image can't be pulled, it pulls latest instead, and says so.

Its layout

Images built FROM the default image can rely on its layout, which stays the same from version to version. The sandbox repository's check.sh checks every build for it:

An image that installs something as root switches back to agent at the end:

FROM ghcr.io/hyperstructure/sandbox:0.5
USER root
RUN apt-get update \
  && apt-get install -y --no-install-recommends postgresql-client \
  && rm -rf /var/lib/apt/lists/*
USER agent

Your own image

When sessions need tools the default image lacks, such as .NET or a company's CLIs, give the project an image that has them. A setup script would install them in every session instead. This example adds the .NET 8 SDK. .NET needs ICU, which Debian has, and the SDK goes into agent's home, like Rust. The PATH is set twice: ENV for Claude and the commands it runs, and /etc/profile.d for login shells, which setup scripts run in:

FROM ghcr.io/hyperstructure/sandbox:0.5

USER root
RUN apt-get update \
  && apt-get install -y --no-install-recommends libicu72 \
  && rm -rf /var/lib/apt/lists/* \
  && printf '%s\n' 'export DOTNET_ROOT="$HOME/.dotnet"' \
    'PATH="$HOME/.dotnet:$HOME/.dotnet/tools:$PATH"' > /etc/profile.d/dotnet.sh
USER agent

RUN curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh \
  && bash /tmp/dotnet-install.sh --channel 8.0 --install-dir /home/agent/.dotnet \
  && rm /tmp/dotnet-install.sh
ENV DOTNET_ROOT=/home/agent/.dotnet \
  DOTNET_CLI_TELEMETRY_OPTOUT=1 \
  PATH=/home/agent/.dotnet:/home/agent/.dotnet/tools:$PATH
  1. Build it for both architectures and push it to a registry: Apple silicon hosts run arm64 images, most others amd64.

    docker buildx build --platform linux/arm64,linux/amd64 \
      -t ghcr.io/acme/dotnet-agent:1 --push .
  2. Choose it for the project. In Settings → Sandbox → Image, pick "An image from a registry" and enter ghcr.io/acme/dotnet-agent:1. From the CLI:

    fctl projects set -p my-app --image ghcr.io/acme/dotnet-agent:1
    fctl projects set -p my-app --image ""     # back to each runner's own

    Maintainers and owners can change it. Sessions that start or resume afterwards in a new sandbox use it; a sandbox that exists keeps the image it was made from.

  3. Hosts run it if their owners allow it. A host that lists allowed_images runs only the images on its list (Which images a host runs); one that lists none runs any.

  4. Private registries. Hosts pull images with their own login to the registry: run docker login ghcr.io (with Apple container, container registry login ghcr.io) on each host, as the user its service runs as. fctl sends no registry credentials. For GHCR, a token with read:packages is enough.

Lima runners don't use images: their sessions run in a clone of the VM fctl host image build prepared. The process driver uses none either.

Building it from the repository

A project can build its image from a Dockerfile or a dev container configuration in its repository instead, so that the environment lives next to the code and changes with it. In Settings → Sandbox → Image, pick "Built from the repository" and name the file, or from the CLI:

fctl projects set -p my-app --build Dockerfile
fctl projects set -p my-app --build docker/agent.Dockerfile --build-context app
fctl projects set -p my-app --build .devcontainer/devcontainer.json
fctl projects set -p my-app --build ""     # stop building

The project needs a repository, and its build replaces any image it chose. Only hosts whose owners allow builds from the repository take its sessions (allowed_builds, below), and only those with the Apple container or Docker driver.

When a session starts in a new sandbox, the host:

  1. Reads the file at the tip of the project's default branch, with the session's GitHub token, and works out what the build reads: the Dockerfile, and the directory it builds in (the repository's root unless the setting or the dev container configuration names another).
  2. Uses its last build when none of that changed since. Changes outside the directory don't count, but with the repository's root as the directory, every change does. In a busy repository, give the image a directory of its own (--build-context docker, say, or the .devcontainer directory a dev container configuration builds in by default); the builder's cache keeps the builds that follow short anyway.
  3. Otherwise downloads the repository at that commit (up to 2 GiB), keeps only the directory, and builds the image with docker build or container build, from the Dockerfile's text as GitHub sent it. The session's log shows each step. Sessions that need the same build wait for the one that builds it. Builds pass no secrets: a Dockerfile that needs a token should leave that step to the setup script.
  4. Tags the image fctl-build-<project id>:<hash>, and removes the project's earlier build, unless the runtime keeps it for a sandbox made from it (Docker does).

When GitHub can't be reached, sessions start from the last build of the same setting and say so; any other problem, like a file that isn't there, fails the session with the reason. A dev container configuration's image, with no build, is used as it is, from its registry.

Limits. A host builds one image at a time, and reads no Dockerfile or configuration larger than 1 MiB. A build that takes longer than build_timeout_minutes (60) is stopped, and an image larger than max_build_mb (20 GiB) is removed, where the runtime reports image sizes (Docker does); the session fails and says why. A build runs in the runtime's builder, not in a sandbox, so the project's CPU and memory settings don't apply: with Docker it gets what the Docker daemon has (on macOS, its VM), and with Apple container what its builder VM has (container builder start --cpus 8 --memory 16g sizes it).

What fctl reads from a dev container configuration. image, or build.dockerfile, build.context, build.args and build.target (dockerFile and context at the top level in older ones), with paths relative to the configuration's directory. It leaves out features, lifecycle commands like postCreateCommand, containerEnv, mounts and runArgs, and says so in the session's log: put what they install into the Dockerfile, and what they run into the project's setup script. Sessions run as the image's user rather than remoteUser, so end the Dockerfile with USER vscode, say, to run as that user. Docker Compose configurations aren't built.

The image still needs what every image needs (below): most dev container images lack Claude Code. Start the Dockerfile FROM ghcr.io/hyperstructure/sandbox:0.5, or install Claude Code in it as the user sessions run as (curl -fsSL https://claude.ai/install.sh | bash).

Which images a host runs

A project's image and its build run on machines that belong to other people, with the session starter's Claude token and the session's GitHub tokens inside them (Security). So each host's owner decides, in its host.toml:

# Images projects may choose; the host's own `image` always runs. Unset: any.
allowed_images = [
  "ghcr.io/hyperstructure/sandbox",                     # any tag of it
  "ghcr.io/acme/*",                                     # anything under ghcr.io/acme/
  "ghcr.io/partner/agent@sha256:4f1c…",                 # this build only
]
# Repositories whose Dockerfile or devcontainer.json it builds. Unset: none.
allowed_builds = ["acme/*", "partner/app"]
build_timeout_minutes = 60
max_build_mb = 20480

The same lists from the CLI, each flag repeated for more entries (--allow-image '*' allows any image, --allow-build "" none):

fctl host install --allow-image 'ghcr.io/acme/*' --allow-image ghcr.io/hyperstructure/sandbox \
  --allow-build 'acme/*'

The host sends the lists to the control plane when it connects, so run fctl host restart after editing host.toml. The control plane gives a session only to a runner that runs its project's image or builds it, and the runner checks again. A session that no runner online allows stays queued and says why: "No runner allows this project's image, ghcr.io/acme/agent:1", or "No runner builds this project's image from acme/app". Admin → Runners, fctl admin runners and fctl host status show each runner's image and lists.

What an image needs

An image that doesn't start from the default one needs:

What fctl writes into a sandbox

At session start, fctl copies fctl-agent into /opt/fctl/bin and starts it as the image's user, which writes:

Claude's environment has the project's variables and secrets, CLAUDE_CODE_OAUTH_TOKEN and its scopes, IS_SANDBOX=1, DISABLE_AUTOUPDATER=1, GIT_TERMINAL_PROMPT=0, FCTL_SESSION_ID, FCTL_SESSION_URL, and the sandbox's size in FCTL_SANDBOX_CPUS and FCTL_SANDBOX_MEMORY_MB. ANTHROPIC_* variables and the Bedrock, Vertex and Foundry switches never reach it.