Skip to content

feat: add Python 3.15, Rust, Bun and TypeScript to the sandbox image - #45

Merged
kalaspuff merged 3 commits into
mainfrom
fctl/18-the-base-agent-image-https-github-com-hy
Oct 18, 2026
Merged

kalaspuff merged 3 commits into
mainfrom
fctl/18-the-base-agent-image-https-github-com-hy

Conversation

@fctl-sh

@fctl-sh fctl-sh Bot commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

The default sandbox image had Node.js, Debian's Python 3.11 and little else, so sessions installed toolchains before they could build anything; #16 notes that a session working on fctl installs rustup and builds from cold.

images/agent/provision.sh now installs, at versions set at its top:

  • Python 3.15.0 as python3 and python for every user: uv's build of CPython in /opt/uv/python, linked from /usr/local/bin. uv is pinned to 0.13.1.
  • Rust 1.99.0 through rustup, with rustfmt, Clippy and wasm32-unknown-unknown: the toolchain rust-toolchain.toml pins, so sessions working on fctl download nothing. A new test in crates/fctl-host/src/image.rs fails when the two drift apart.
  • TypeScript 7.0.2 (tsc) and Bun 1.4.3 beside Node.js 22.
  • cmake, OpenSSL's headers, wget, rsync, lsof, psmisc (fuser, killall), zstd, ShellCheck and sqlite3, and fd as a link to Debian's fdfind.

Decisions:

  • Rust and Bun live in the agent's home (~/.rustup, ~/.cargo, ~/.bun). fctl-agent starts Claude with only PATH, HOME, the locale and a few login variables (crates/fctl-agent/src/claude.rs), so a system-wide rustup, which needs RUSTUP_HOME, would not work in Claude's shell. In the home, both work from HOME and PATH alone, and agents add toolchains, targets and global tools without sudo.
  • Python is system-wide, so root and images built FROM this one (feat(host): pull the sandbox image from GHCR instead of building it #39) get the same interpreter, with venv and pip. Debian's python3-pip and python3-venv go; its python3 stays, behind /usr/local/bin on the PATH, because NodeSource's nodejs depends on it.
  • One PATH. The Dockerfile's ENV PATH gains ~/.cargo/bin and ~/.bun/bin, and /etc/profile.d/fctl.sh sets the same for login shells: setup scripts run with bash -lc, and limactl shell runs the Lima driver's commands in one. It replaces the ~/.profile line, which was never written: its guard grepped for .local/bin, which Debian's skeleton .profile already contains, so login shells lost /opt/fctl/bin.
  • npm leaves out its funding and audit notices and its update check (/usr/etc/npmrc), which only lengthened agents' output; npm audit still works. CI already runs npm ci --no-audit --no-fund.
  • Left out: git-lfs, because Debian's package turns on LFS downloads for every clone, which would change how sessions clone LFS repositories and spend their LFS bandwidth; Go, Java and the like, for the image's size. Node.js stays at 22, as in CI.

The image grows by about 0.9 GB uncompressed on arm64: Rust 640 MB, Python 96 MB, Bun 85 MB, TypeScript 30 MB and about 100 MB of Debian packages (ShellCheck alone is 41 MB).

Verification

  • provision.sh ran end to end on this session's sandbox (Debian bookworm, arm64) for a fresh user, three times as it changed. Its last step prints every tool's version from the agent's login shell: Claude Code 2.1.302, fctl 0.2.0, Python 3.15.0, uv 0.13.1, rustc and cargo 1.99.0, Node.js 22.23.3, tsc 7.0.2 and Bun 1.4.3.
  • As that user, with only HOME and the Dockerfile's PATH, as fctl-agent starts Claude: python3 -m venv with pip, a C extension built from source, uv venv, uv pip install and uv run --with; cargo run, a wasm32-unknown-unknown build, cargo fmt, cargo clippy and a crate that links OpenSSL; tsc and Bun compiling and running TypeScript; npm install without funding or audit lines; binaries from bun add -g and uv tool install found on the PATH; and every CLI tool above. With this repository's rust-toolchain.toml, rustup selects the installed 1.99.0 and downloads nothing.
  • cargo xtask check passes. The new test fails, saying what to change, when RUST_VERSION or a target differs from rust-toolchain.toml.
  • ShellCheck is clean on provision.sh.
  • Not done: a docker build (this sandbox has no container runtime) and amd64. feat(host): pull the sandbox image from GHCR instead of building it #39's Image workflow builds the image for both architectures and runs check.sh on pull requests that change images/agent, so it covers this change once either pull request is on main.

Overlap with #39

#39 also edits the Dockerfile's header comment, images/agent/README.md, docs/architecture.md and docs/getting-started.md; provision.sh merges cleanly. Whichever lands second keeps both sides' facts. With both in, docs/sandbox-images.md should list these tools and the PATH (~/.local/bin, ~/.cargo/bin, ~/.bun/bin, /opt/fctl/bin), and check.sh could check for cargo, tsc and bun.

Refs #16

Session: https://app.fctl.sh/s/ses_01M56D7AEK3XQYQJH6TQXYMDZ5

fctl-sh Bot and others added 3 commits October 18, 2026 02:53
The default image had Node.js and Debian's Python 3.11, so sessions
installed everything else themselves: a session working on fctl spent
minutes and gigabytes on rustup before it could build.

provision.sh now installs, at versions set at its top:

- uv 0.13.1 and Python 3.15.0 (uv's CPython) as python3 and python;
- Rust 1.99.0 with rustfmt, Clippy and wasm32-unknown-unknown, the
  toolchain rust-toolchain.toml pins (a test keeps the two in step);
- TypeScript 7.0.2 and Bun 1.4.3 beside Node.js 22;
- cmake, libssl-dev, wget, rsync, lsof, psmisc, zstd, ShellCheck and
  sqlite3, and an `fd` link to Debian's `fdfind`.

Python, Rust and Bun live in the agent's home: fctl-agent starts Claude
with only PATH, HOME and the locale, so tools that need RUSTUP_HOME and
the like would not work, and agents can add versions without sudo. The
PATH is the Dockerfile's, and /etc/profile.d/fctl.sh gives login shells
(setup scripts, Lima) the same one; the ~/.profile line it replaces was
never written, because Debian's skeleton .profile already mentions
.local/bin. npm stops printing funding and audit notices.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
uv's CPython goes to /opt/uv/python with python3 and python in
/usr/local/bin, instead of the agent's home: root and images built from
this one (which run apt and pip steps as root) get the same Python, with
venv and pip, as the agent. Debian's python3-pip and python3-venv go;
its python3 stays because NodeSource's nodejs depends on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t-image-https-github-com-hy

# Conflicts:
#	docs/architecture.md
@kalaspuff
kalaspuff merged commit 50cd637 into main Oct 18, 2026
4 checks passed
@kalaspuff
kalaspuff deleted the fctl/18-the-base-agent-image-https-github-com-hy branch October 18, 2026 03:15
fctl-sh Bot added a commit that referenced this pull request Oct 18, 2026
…ps-github-com-hyperstructur

Brings in #45 (Python 3.15, Rust, Bun and TypeScript in the sandbox
image) and the v0.3.0 release. The conflicts in the Dockerfile's header,
images/agent/README.md, docs/architecture.md and docs/getting-started.md
keep both sides: #45's tools, versions and PATH, and this branch's
published image, check.sh and the host pulling the image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fctl-sh Bot added a commit that referenced this pull request Oct 18, 2026
#45 added Python 3.15, Rust, Bun, TypeScript and more Debian tools to the
image, and ~/.cargo/bin and ~/.bun/bin to the PATH, which
/etc/profile.d/fctl.sh now also sets for login shells. The layout section
is what images built FROM the default rely on, so it lists them, and says
that Rust and Bun belong to the agent user and that Python is externally
managed.

0.3.0 shipped without this branch, so its hosts still build the image
themselves; the examples use 0.4.0, the first release whose hosts pull it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fctl-sh Bot added a commit that referenced this pull request Oct 18, 2026
check.sh now checks what docs/sandbox-images.md lists since #45: the four
directories that lead the PATH, in the image and in login shells (from
/etc/profile.d/fctl.sh); Python, Rust, TypeScript, Bun and the other
tools; that rustup's proxies, Bun and tsc run from HOME and the PATH
alone; and OpenSSL's headers.

The Image workflow ran this commit's check.sh against images built from a
release's tag. Once main's images/agent gains a tool and check.sh checks
for it, the daily rebuild of the latest release, which lacks it, would
fail until the next release. A build now runs its release's own check.sh,
and this commit's only for releases from before check.sh (v0.3.0 and
earlier).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kalaspuff pushed a commit that referenced this pull request Oct 18, 2026
On a machine without `worker-build`, `cargo xtask dev` built everything
and then failed when wrangler started the control plane:
`crates/fctl-control/wrangler.toml` builds the Worker with `worker-build
--dev`, and only `cargo xtask deploy` installed it (CI installs it in a
step of its own). xtask then kept waiting for the health check, which
gives up after five minutes.

`cargo xtask dev` now calls `util::worker_build()`, as `deploy` does. It
installs worker-build 0.8.7, the version of the `worker` crate in
`Cargo.lock`, with `cargo install` when it isn't on the PATH. The call
comes first, right after `npm ci`, so a machine that can't build
worker-build finds out in seconds, not after the other builds.
`docs/development.md` lists worker-build in the setup, with what its
build needs on Debian and Ubuntu: `pkg-config` and `libssl-dev`, because
on Linux its HTTP client links OpenSSL.

I found this while checking #34 on a fresh clone. #33 had already fixed
the Claude credential bootstrap that #34 reports, but `cargo xtask dev`
never got far enough to run it.

## How I verified it

From a fresh clone (no `.fctl-dev/`, `crates/fctl-control/.wrangler/`,
`node_modules` or `web/dist`), in a sandbox with Rust but no
worker-build:

- **Before, on `main`:** `[custom build] /bin/sh: 1: worker-build: not
found`, then `Running custom build 'worker-build --dev' failed`, while
xtask went on waiting for the health check.
- **Without OpenSSL's headers:** it stops after 25 s, before the other
builds, with openssl-sys's "The system library `openssl` required by
crate `openssl-sys` was not found".
- **With them:** it installs worker-build, builds and bootstraps. `POST
/api/v1/setup`, `/auth/tokens`, `/me/claude/token`, `/projects` and
`/runners` all return 201, and it reaches "fctl dev is running" in 79 s.
Vite serves the app at http://localhost:5173 (`npm ci` fetched the
design package). Two fake-claude sessions ran to the end: `write
notes.md` committed a file, and `rename Check the dev bootstrap` renamed
its session with `fctl edit`.
- A restart on that state reaches "fctl dev is running" in 6 s and adds
nothing. With `.fctl-dev/token` deleted, it signs in with the password
and makes a new token.
- `cargo xtask check` passes.

## Not changed

- When `wrangler dev` exits before it is healthy (any failing first
build, a compile error included), `cargo xtask dev` still waits the full
five minutes, because `wait_for` doesn't watch the wrangler process.
- #34 left a question for a person: whether CI should run the bootstrap.
It is now #53, with this as a second break CI didn't catch.
- #41's question, whether the default sandbox image should include Rust
and `libssl-dev`, was answered by #45, which added both along with
`pkg-config`.

Fixes #41
Refs #34

Session: https://app.fctl.sh/s/ses_01M56GFH2FJV0NW90ZNRFWKEA3

Co-authored-by: fctl-sh[bot] <343971779+fctl-sh[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
kalaspuff pushed a commit that referenced this pull request Oct 18, 2026
)

Every host built the default sandbox image itself with `fctl host image
build`, and its Claude Code stayed as old as that build, since fctl
turns off Claude Code's auto-updater. #2 asks for CI to publish the
image instead, for arm64 and amd64, under a stable name with version
tags and labels, rebuilt when Claude Code moves on, with a documented
layout that custom images (#16) can build `FROM`; hosts on the default
image pull the tag of their own version.

The image now lives in a public repository of its own,
[hyperstructure/sandbox](https://github.com/hyperstructure/sandbox) (set
up in hyperstructure/sandbox#1), which publishes it as
`ghcr.io/hyperstructure/sandbox`. This pull request makes hosts pull it
and takes the image out of this repository.

## What changed

**The image moves to hyperstructure/sandbox.** `images/agent` (the
Dockerfile, `provision.sh`, `check.sh`) and the Image workflow leave
this repository; hyperstructure/sandbox#1 adds them there, with a
README, AGENTS.md, skills, lint and title checks. Its Image workflow
keeps what this pull request had built: each architecture builds on a
native runner and is checked with `check.sh`, then both are tagged as
one image.

- `ghcr.io/hyperstructure/sandbox:<version>` for every fctl release,
plus `:<major>.<minor>` and `:latest` when that release is the newest
they cover. Each is the sandbox's `main` with that release of fctl and
the newest Claude Code.
- It looks for new fctl releases on fctl.sh every hour, rebuilds the
newest release's image as soon as a change to the image merges there,
and every day when Claude Code has a newer release or the image is a
week old. Only its `main` publishes; its pull requests build and check
both architectures.
- Labels and index annotations as before: `sh.fctl.version`,
`sh.fctl.claude-code.version`, and the commit and date it was built
from, which the workflow reads back to decide whether an image is
current.

The Release workflow no longer starts an image build: its token can't
start workflows in another repository, so the sandbox's hourly run
notices the release. Why a separate repository: [ADR
0012](docs/adr/0012-the-sandbox-image-lives-in-its-own-public-repository.md)
(the image has to be public, and it has a cadence of its own). It's 0012
because #55 adds 0011.

**Hosts: `crates/fctl-host`, `crates/fctl`**

- `image` in `host.toml` is optional. Unset, sessions start from
`ghcr.io/hyperstructure/sandbox:<the host's version>`, so upgrading a
host moves its new sessions to the new version's image.
`fctl-agent:latest`, which every host installed so far has in its
`host.toml`, also means the default, in `host.toml` and in project
settings.
- The daemon pulls the host's image in the background when it starts and
the image is missing. A session pulls a missing image itself. Nothing
pulls over an image that is already there, so a local build is kept.
- When the version's image can't be pulled (the hour between a release
and its image, or a development build), a session on the default image
starts from `:latest` and says so in its log. A failure that isn't about
the image (the image is there but `run` fails) no longer tries a pull.
- Apple's `container image pull` gets `--platform linux/<the host's
architecture>`. Without it, Apple's CLI downloads every platform of a
multi-arch image (`DefaultPlatform.resolve` in apple/container).
- `fctl host image build` downloads an archive of the sandbox's `main`
from GitHub and builds its Dockerfile with the host's driver, under the
host's image name so sessions use the build, or gives the Lima VM its
`provision.sh`. `--source DIR` builds from a checkout instead, to try
changes to the image with fctl. The binary no longer carries the files.
- CLI: `fctl host image pull` (new, `--json`) pulls the host's image
again, for a newer build of its tag. `install` says which image sessions
start from, `status` (and `--json`) shows it, `doctor` says whether it's
there, and `install --image ""` goes back to the default.
- A sandbox without fctl now says in its log that the default image has
one, instead of suggesting `fctl host image build`.

**Docs:** `docs/sandbox-images.md` (new) covers the image, its tags and
labels, how hosts get it, and its layout as an interface for images
built `FROM` it: the `agent` user (uid 1000, passwordless sudo),
`~/.local/bin`, `~/.cargo/bin`, `~/.bun/bin` and `/opt/fctl/bin` first
on the PATH, and the tools. It's the "Sandbox images" page #16 asks for,
which #16 can extend; the image's README in hyperstructure/sandbox lists
the versions. Getting started, the CLI, self-hosting, security,
architecture, development and the top-level README say that hosts pull
the image instead of building it. AGENTS.md lists hyperstructure/sandbox
among the other repositories (what the host relies on there, and that
its Rust follows `rust-toolchain.toml`), and the release and issues
skills point to it. The roadmap item is ticked.

**Web:** the runner dialog drops step 3 ("Build the sandbox image"), and
the project's image field shows `ghcr.io/hyperstructure/sandbox` as its
placeholder instead of `fctl-agent:latest`.

## For a person

0.3.0 shipped without this pull request, so the next release (0.4.0) is
the first whose hosts pull the image.

1. **Merge hyperstructure/sandbox#1.** Its Image workflow then publishes
`ghcr.io/hyperstructure/sandbox:0.3.0`, `:0.3` and `:latest` from the
sandbox's `main`, which creates the package.
2. **Make hyperstructure/sandbox public** and apply its settings: the
commands are in hyperstructure/sandbox#1.
3. **Make the package public**: the organization's Packages → sandbox →
Package settings → Change visibility. A package starts private even when
its repository is public, and hosts pull without signing in. The
organization's settings must allow public packages. Until then, sessions
on hosts of 0.4.0 fail with "ghcr.io/hyperstructure/sandbox:0.4.0 is not
on this host and can't be pulled (`fctl host image build` builds it
here)".
4. **Merge this pull request**, then hyperstructure/fctl-site#7 once
fctl's CI has pinned fctl.sh past it (`gh pr update-branch 7 -R
hyperstructure/fctl-site`).

## Compatibility

- No protocol or control-plane change. `HostHello.image`, already
optional, now carries the image the host actually uses. Hosts pick up
the new behavior with the release that includes it; hosts of 0.3.0 and
earlier keep building `fctl-agent:latest` from the files in their
binary.
- `image = "fctl-agent:latest"` in `host.toml` now means the published
image. A host that ran a modified image under that name should rename it
and set `image`, or rebuild it with `fctl host image build`, which tags
it with the default name.
- `fctl host image build` needs to reach github.com, as its build
already needed Debian's mirrors and the tools' installers.
- Hosts keep the images of earlier versions until someone removes them
(`docker image rm …`), as they kept the images `fctl host image build`
replaced. Removing them automatically, and refreshing a tag when its
image is rebuilt, is #47.

## Verification

- **The sandbox's Image workflow** ran on hyperstructure/sandbox#1: both
architectures built natively (amd64 in 2m56s, arm64 in 2m20s) and
`check.sh` passed in each with fctl 0.3.0 and Claude Code 2.1.302
([run](https://github.com/hyperstructure/sandbox/actions/runs/39123588178)).
Its lint (ShellCheck, actionlint) and title checks pass too.
- **Its planning step**, run locally against stubbed fctl.sh,
downloads.claude.ai and registry responses, decides correctly in 19
cases: a pull request; hourly with no image (build), a current image
with an older Claude Code (skip: hourly runs ignore Claude Code), an
image built from the last change to the image (skip) and one from before
it (build), an index without annotations (build); daily with an older
Claude Code or a week-old image (build) and a current one (skip); a push
after a change (build); dispatches of 0.2.0 (no `:0.2`: 0.2.1 exists),
v0.2.1 (`:0.2`), a current image with an older Claude Code (build), a
forced rebuild, another branch (build without publishing), a version
fctl.sh doesn't have and a malformed one (errors); and odd answers from
fctl.sh and downloads.claude.ai (errors).
- **`fctl host image build`** with a stand-in `docker`: `--source` with
a checkout builds it with `FCTL_VERSION=0.3.0` and
`FCTL_DOWNLOAD=https://fctl.sh/releases`. The download path, pointed at
a `git archive` of the sandbox served locally (GitHub's format, with its
pax header), unpacks it, builds `…/sandbox-main` and removes the
temporary directory; pointed at a real GitHub archive of a public
repository, it follows the redirect, unpacks it and says the result has
no `provision.sh`; against the real URL while the repository is still
private, it fails with HTTP 404. Unit tests cover unpacking a
GitHub-style archive and refusing a checkout without `provision.sh`.
- **End to end with the stand-in:** an old `host.toml` with
`fctl-agent:latest` resolves to `ghcr.io/hyperstructure/sandbox:0.3.0`
in `status` and `status --json`; `doctor` reports it "not here yet",
then ✓ after `fctl host image pull --json`.
- **Unit tests:** the config tests cover unset, empty and old-default
images, a custom image and saving. The driver tests use a stand-in
`docker`/`container` CLI with an image store and a registry: an image
already there, a pull, Apple's `--platform`, falling back to `:latest`,
both images missing (with and without a fallback), and a `run` failure
that isn't about the image.
- `cargo xtask check --quick` passes locally on the move and on the
merges with `main` (#48, #50, #52, then #54, #57, #58), and CI's full
`cargo xtask check` passes. The merge with #57 keeps its startup retry
loop and starts the background pull once the runtime answers; a `run`
that times out under #57's limits now fails at once instead of being
taken for a missing image and trying a pull.
- hyperstructure/fctl-site#7, with its `main` merged, builds against
this branch's docs: 8 pages, Sandbox images among them, linking to
hyperstructure/sandbox.
- Earlier on this pull request, before the move: the publish step's
commands against a local registry (one OCI index for both architectures
that all three tags point to, with the annotations) and `fctl host
install --driver docker` with a stand-in, which writes no `image` and
says which image sessions start from.
- **Not verified here:** pushing to GHCR with the sandbox workflow's
token, creating the package and its visibility. They need `packages:
write` on the sandbox's `main`, so merging hyperstructure/sandbox#1 is
their test, well before a release depends on them.

## Screenshots

Sessions can't attach screenshots yet (#40), so here is what they show.
Taken at 7771600 with Playwright against `cargo xtask dev` (before:
`main` from a worktree), in both themes and at 390 px:

- **Runner dialog, "Bring studio mac online":** before, three numbered
steps: 1. Install fctl, 2. Register it as a runner and start the
service, 3. Build the sandbox image (once, a few minutes) with `fctl
host image build`. After, steps 1 and 2 only, and the note under them
reads "It shows up here as online within seconds and pulls the sandbox
image in the background. With Lima, prepare the base VM once with `fctl
host image build`. `fctl host doctor` checks everything, including
whether the Mac is set to never sleep." Both themes and the phone width
wrap cleanly.
- **Project → Settings → Sandbox:** the Image field's placeholder reads
`ghcr.io/hyperstructure/sandbox` (at 7771600 it read
`ghcr.io/hyperstructure/fctl-agent`; `main` has `fctl-agent:latest`).
Nothing else in the section changes. The move changed only this
placeholder's text, which wasn't screenshotted again.

Refs #2

Sessions: https://app.fctl.sh/s/ses_01M56C4589JCHJ2W8TXC8V2S4P,
https://app.fctl.sh/s/ses_01M56FZDD6WY8J56E3TA0SEMZX for merging #45 and
v0.3.0, and https://app.fctl.sh/s/ses_01M57B991V0YYBRJFN0WKEW118 for
moving the image to hyperstructure/sandbox

---------

Co-authored-by: fctl-sh[bot] <fctl-sh[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
kalaspuff pushed a commit that referenced this pull request Oct 18, 2026
Finishes #16. A project starts its sessions from an image of its own, or
from one that hosts build out of a Dockerfile or devcontainer.json in
its repository. Host owners decide which images their machines run and
which repositories they build from, and sessions go only to runners that
allow their image. The docs now say how to do all of it.

Fixes #16

## What changed, by the issue's points

1. **The contract** (`docs/sandbox-images.md`). "What an image needs"
now lists `gh`, a non-root user, both architectures and the oldest
Claude Code fctl works with. That is **2.1.101**, the first release that
asks its host for fresh OAuth tokens (`oauth_token_refresh`;
`remote_control` came in 2.1.63). I found both by checking published
Claude Code builds, because the changelog doesn't say. fctl-agent now
runs `claude --version` before it starts Claude, and fails the session
with a clear message when Claude Code is missing or older. "What fctl
writes into a sandbox" is new. The worked example adds .NET rather than
Rust, since the default image has had Rust since #45.
2. **CLI.** `fctl projects set` gets `--image`, `--build` /
`--build-context`, `--setup-script` (the script, `@FILE`, or `-` for
stdin), `--env` / `--unset-env`, and `--cpus` / `--memory` (`default`
for the runner's own), with `--json` like the rest. `fctl projects show`
prints them.
3. **The image's Claude settings stay.** fctl-agent writes its own keys
over `~/.claude/settings.json` instead of replacing the file. It owns
the permission mode, `attribution`, `disableClaudeAiConnectors` and the
auto-updater. It drops `apiKeyHelper`, `ANTHROPIC_*` and provider
variables, and `permissions.disableBypassPermissionsMode`. Plugins,
marketplaces, hooks and permission rules stay.
4. **Builds from the repository** (`crates/fctl-host/src/build.rs`). The
new project setting `image_build` names a Dockerfile (plus the directory
it builds in) or a devcontainer.json.
- **When it builds:** a host that allows it reads the file at the tip of
the default branch through GitHub's API, using the session's token. It
hashes the file, the Dockerfile and the git tree of the build directory,
and builds only when no image has that hash.
- **How it builds:** it downloads the repository archive (up to 2 GiB)
and unpacks only the build directory and the Dockerfile. It sends the
token only to GitHub's API origin. Then it runs `docker build` or
`container build`, one build at a time.
- **Limits:** `build_timeout_minutes` (60) and `max_build_mb` (20 GiB).
It keeps each project's newest build, and starts from the last build
while GitHub is unreachable.
- **Dev containers:** fctl reads `image`,
`build.dockerfile`/`context`/`args`/`target`. It leaves out features and
lifecycle commands, and says so in the session's log.
5. **Host owners decide what runs.**
- `host.toml` gets `allowed_images` (references, `/*` prefixes, digest
pins) and `allowed_builds` (`owner/name`, `owner/*`). `fctl host
install` gets `--allow-image` and `--allow-build` for them.
- Hosts send both as optional hello fields. FleetDO assigns a session
only to a runner that runs or builds its image
(`fctl_domain::schedule::pick`). A session no runner takes waits and
says why ("No runner allows this project's image, …" / "No runner builds
this project's image from …").
- The host checks again before it accepts a session. `docs/security.md`
says that whoever sets the image, the build or the setup script can read
the credentials of the people who start sessions.
6. **The app.** Settings → Sandbox → Image picks each runner's own
image, an image from a registry, or a build from the repository. Its
hint says which drivers use images. Admin → Runners, `fctl admin
runners` and `fctl host status` show each runner's image and what else
it runs and builds.
7. **Private registries.** Hosts pull with their own login (`docker
login`, `container registry login`), and the docs say so. fctl stores no
registry credentials.

ADR 0017 records the decisions. One worth a look: `allowed_images` unset
still means any image, so upgrading no host stops a project's sessions.

## Compatibility

- **New fields:** all optional. `ProjectSettings.image_build` is always
serialized, as `null` when there is none. `HostHello.allowed_images` /
`allowed_builds` and `Runner.image` / `allowed_*` are skipped when
empty.
- **Older hosts** send no lists: they run any image and build none, as
before. Older agents ignore `image_build`.
- **Older clients** (an old web tab, the previous CLI) that send
settings without `image_build` keep a project's build, unless they
choose an image instead (`settings::keep_unsent`).
- **Validation:** image references are now validated with Docker's
grammar.
- **Images with Claude Code older than 2.1.101** now fail at start with
a message. Before, they failed once an OAuth token expired.

## How I verified it

- **Rust:** `cargo xtask check` passes (fmt, generated types, Biome and
TypeScript, Clippy native and wasm, all tests, the web build). New unit
tests cover:
  - reference parsing and pattern matching, and scheduling by image;
  - `keep_unsent` and build validation;
- JSONC and devcontainer parsing, the build key, tar unpacking (hard
links skipped, nothing escapes through links);
  - the settings merge and the version check.
- One host test builds end to end against a fake GitHub (API, plus a
second origin for the archive) and a fake `docker`. It covers: build
once, reuse, no rebuild after an unrelated commit, rebuild after a
change in the build directory or the Dockerfile, removal of the old
build, fallback while GitHub is down, a failing build, the size cap, and
a devcontainer `image`. It also caught the token going to the archive's
redirect origin, which is fixed.
- **Web:** Vitest covers the image chooser's state and the runner
descriptions.
- **Dev stack** (`cargo xtask dev --no-host`, two hosts I registered
with the new flags: a process-driver host allowing `ghcr.io/acme/*`, and
a Docker-driver host behind a stand-in `docker` that builds from
`acme/*`):
- a project image no runner allows waited with the reason above; after
switching to an allowed image, the waiting session started on the right
host and finished its turn;
- a build from `other/tool` waited ("No runner builds this project's
image from other/tool"); a build from `acme/app` went to the building
host, and stopped there only because the dev stack has no GitHub App;
- stopping and resuming a session merged seeded "image" settings:
plugins, hooks and `RUST_LOG` stayed, `apiKeyHelper` and
`ANTHROPIC_BASE_URL` were gone;
- the CLI output, `fctl admin runners` and `fctl host status` read as
described.
- **The .NET example's steps** ran in this sandbox, which runs the
default image: SDK 8.0.426 installed, a console app built and ran, and
the `profile.d` snippet set the PATH for login shells.
- **Docs:** hyperstructure/fctl-site built with these docs, and every
link and anchor between pages resolves.
- **Independent review:** a separate agent reviewed the whole diff, and
b59b708 fixes what it found, with tests for each:
  - a tag in `allowed_images` allowed that tag pinned to any digest;
  - reading a large `.json` build file was quadratic;
- the host fell back to the last build after any error, not only when
GitHub was unreachable;
  - a Dockerfile that is a symlink would have been read on the host;
  - Docker Hub prefixes written with `docker.io` matched nothing;
- smaller ones: a stale build directory in the CLI, leftover build files
after a stop, a signed download link in error messages, a few gaps in
reference parsing, and the chooser forgetting typed values.

**Not verified:** a real `docker build` or Apple `container build`. This
sandbox has neither runtime, so the builder commands, `container image
inspect` sizes and `container image delete` are untested on real
runtimes. A first build on a Docker host and on a Mac host is worth
doing before relying on it.

## Not in this change

For a person to decide; I can open issues for them:

- Hosts that run only their own image unless their owner says otherwise.
- Registry credentials from project secrets.
- Dev container features, which need the Dev Container CLI.

## Other repositories

- hyperstructure/fctl-site: hyperstructure/fctl-site#16 updates the
privacy policy. Hosts now pull the images projects choose, download
repositories from GitHub to build them, and report the images and
repositories they allow.
- hyperstructure/sandbox needs no change: the image's layout is the
same, and it ships the newest Claude Code.

## Screenshots

Taken at 809b7b2 against `cargo xtask dev` with two dev hosts, 1440 px
wide unless noted. Later commits don't change these screens.

**Settings → Sandbox, a project that builds its image.** Before, the
form has no way to show a build; after, the Image choice, the file and
the directory it builds in.

| Before | After |
|---|---|
| ![Before: the Sandbox section has only an image
field](https://app.fctl.sh/api/v1/published/F0fZAliXtluplWuc4dCjpJNLEoojVcEu2SVzZeqgxK7/before-build-light.png)
| ![After: Image set to "Built from the repository", with File and
Directory](https://app.fctl.sh/api/v1/published/Ud5Ese2ZT9AXSkc9YDlJuJ4GN6Xytp7LrdVpn8iBSLj/after-build-light.png)
|

The three choices, an image from a registry with its hint, and the dark
theme at 390 px:

| Choices | An image | Dark, 390 px |
|---|---|---|
| ![The Image select open: each runner's own, an image from a registry,
built from the
repository](https://app.fctl.sh/api/v1/published/8AvkYMocuajdDoZcHKYjJEFF5b1lzSZ4J1I1xs7dhlq/after-select-light.png)
| ![An image from a registry, with the reference
ghcr.io/acme/agent:1](https://app.fctl.sh/api/v1/published/jLYdeDvohoEEgZXaevLCJ1ke10ybddTY4wzUw7OMiPR/after-image-light.png)
| ![The build fields stacked at phone width, dark
theme](https://app.fctl.sh/api/v1/published/BQPzSA5mKwJjvk7MxcpT77nlrZxmRvP68yK9d4rV68c/after-build-dark-390.png)
|

**Admin → Runners.** Each runner now says its image, what else it runs
and what it builds.

| Before | After |
|---|---|
| ![Before: runners without their
images](https://app.fctl.sh/api/v1/published/ixPw7Ofey4MARsV7gMKq6pfbcN89LVOFw582axFUMkc/before-runners-light.png)
| ![After: "Image ghcr.io/hyperstructure/sandbox:0.5.0 · runs its own
image and ghcr.io/acme/* · builds no
images"](https://app.fctl.sh/api/v1/published/BYvDAQnO0mViAWoWlcMNeCsr4NVVFBiMe5osLj6E4Xn/after-runners-light.png)
|

![After, dark
theme](https://app.fctl.sh/api/v1/published/qCUw03CLKR5nNr6EpWO33yF19SE4AXvsQyZewLpx8dT/after-runners-dark.png)

Session: https://app.fctl.sh/s/ses_01M57YPG1ATK9ARAXTZG43G51J

---------

Co-authored-by: fctl-sh[bot] <343971779+fctl-sh[bot]@users.noreply.github.com>
Co-authored-by: fctl-sh[bot] <fctl-sh[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant