Skip to content

feat(host): pull the sandbox image from GHCR instead of building it - #39

Merged
kalaspuff merged 15 commits into
mainfrom
fctl/14-implement-https-github-com-hyperstructur
Oct 18, 2026
Merged

kalaspuff merged 15 commits into
mainfrom
fctl/14-implement-https-github-com-hyperstructur

Conversation

@fctl-sh

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

Copy link
Copy Markdown
Contributor

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 (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 (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 feat: add the sandbox image, its publishing workflow and docs 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 feat: add the sandbox image, its publishing workflow and docs 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 docs: add the sandbox images page and say hosts pull from ghcr.io 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 Hosts never refresh a rebuilt sandbox image or remove old ones #47.

Verification

  • The sandbox's Image workflow ran on feat: add the sandbox image, its publishing workflow and docs 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). 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 (feat: notify people when a session waits for them, pauses or fails #48, ci(release): build the binaries in parallel and without warnings #50, fix: stop removed browsers coming back and digests replacing each other #52, then fix(xtask): install worker-build when cargo xtask dev needs it #54, fix: keep runners online and agents reachable when Docker hangs #57, feat: show Claude plan usage in Settings, on the floor and in the CLI #58), and CI's full cargo xtask check passes. The merge with fix: keep runners online and agents reachable when Docker hangs #57 keeps its startup retry loop and starts the background pull once the runtime answers; a run that times out under fix: keep runners online and agents reachable when Docker hangs #57's limits now fails at once instead of being taken for a missing image and trying a pull.
  • docs: add the sandbox images page and say hosts pull from ghcr.io 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 feat: add the sandbox image, its publishing workflow and docs 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

fctl-sh Bot and others added 4 commits October 18, 2026 02:31
The Image workflow builds images/agent at a release's tag on a native
runner per architecture, checks each build with images/agent/check.sh,
and tags both as ghcr.io/hyperstructure/fctl-agent:<version>, and
:<major>.<minor> and :latest when that release is the newest they cover.
The Release workflow starts it for every release; a daily run rebuilds
the latest release's image when Claude Code has a newer release than the
one inside or the image is a week old. Pull requests that change the
image build it without publishing.

provision.sh installs the Claude Code version it is given, so both
architectures get the one the image is labelled with.

Refs #2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A host whose host.toml sets no image now runs sessions from
ghcr.io/hyperstructure/fctl-agent:<its version>, so upgrading the host
moves new sessions to the new version's image. `image` is optional in
host.toml; the old default's name, fctl-agent:latest, which every host
installed until now has there, means the default again, for the host and
for projects.

- The daemon pulls the host's image in the background when it starts and
  the image isn't there. Sessions pull a missing image themselves, never
  over one that is there, so a local build is kept.
- A session on the default image whose version can't be pulled (in the
  minutes before a release's image is published) starts from :latest and
  says so in its log. Failures other than a missing image no longer try
  a pull.
- Apple's container pulls only the host's platform: without --platform it
  downloads every platform of an image.
- `fctl host image pull` pulls the host's image again, for a newer build
  of its tag; `fctl host image build` builds it under the host's image
  name; install, status and doctor show the image.
- The web app's runner steps no longer ask to build the image.

Refs #2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/sandbox-images.md says where the default image is published, its
tags and labels, how hosts get it, and the layout images built FROM it
rely on: the agent user (uid 1000, passwordless sudo), ~/.local/bin and
/opt/fctl/bin first on the PATH, and the tools. Getting started, the CLI,
self-hosting, security, the architecture and the release skill follow:
hosts pull the image instead of building it.

Refs #2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fctl-sh Bot and others added 3 commits October 18, 2026 02:59
The image workflow pushes each architecture with OCI media types, so the
index it tags them under, which carries annotations, doesn't mix them
with Docker's. The Release workflow starts it after starting CI, so that
a failed dispatch can't hold back the redeploy of app.fctl.sh. The runner
dialog says the host pulls the image in the background.

Refs #2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ps-github-com-hyperstructur

# Conflicts:
#	docs/architecture.md
@fctl-sh
fctl-sh Bot marked this pull request as ready for review October 18, 2026 03:03
kalaspuff pushed a commit that referenced this pull request Oct 18, 2026
…45)

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
(#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. #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

---------

Co-authored-by: fctl-sh[bot] <343971779+fctl-sh[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
fctl-sh Bot and others added 3 commits October 18, 2026 03:22
…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>
#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>
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
In #46 a host stopped serving a session for good: the transcript froze
on a spinning tool call while the agent kept working, and ten minutes
later the fleet stopped the session. Whatever set it off, the host had
no way out once Docker stopped answering. Every `docker …` call was
awaited without a deadline, several of them on the control connection
itself (`hello()` probed every driver and ran `docker info`; the hourly
sweep and the cleanup of sandboxes the control plane asked to stop ran
inside the serve loop), and nothing noticed an agent link that went
quiet or a `docker exec` that never reached the sandbox.

This is the host's side of #46, items 2 to 5 of its checklist.

**Driver commands time out.** Every command a driver runs is killed
after a minute, or after 30 minutes for image pulls, `docker run` (which
may pull) and Lima's clone, start and stop, and the host logs which one:
``WARN `docker ps -a …` did not finish within 60 seconds, so the host
stopped it``. Probes report "the docker daemon is not answering". A
container whose state can't be read now fails the session after a
minute, instead of the host going on to wait 30 minutes for `docker
run`.

**The control connection never waits on a driver.** The hello reports
driver probes and sandbox limits that the host measures at start and
every five minutes in the background. A Docker daemon that doesn't
answer leaves the limits as they were. The hourly sweep and the stopping
of leftover sandboxes run as chores in tasks of their own, and a session
that starts in a sandbox a chore holds waits for it. Heartbeats and
pongs keep flowing, and a message that can't be sent waits for the next
connection instead of being dropped.

**Agent links are checked** (`crates/fctl-host/src/liveness.rs`). The
agent must say hello within 30 s of attaching, and the host pings it
every 15 s (every fctl-agent answers pings). A link with no frame for a
minute is dropped, which kills its exec. The host then attaches again
from a task of its own, waiting 1 s, then twice as long each time up to
30 s, and starting fctl-agent again first. A runtime that hangs is
retried for as long as it takes. Attaches that fail outright (the exec
exits, the sandbox is gone) fail the session after two minutes, as five
failed attaches did before. Deliveries the agent hasn't confirmed go
over the new link again (the agent ignores ones it has), so a message
sent during a stall isn't lost until the uplink happens to reconnect.

**Both sides log the link.** The host logs each attach with the agent's
journal position, each new attempt with its reason, and the exec's
stderr. fctl-agent logs host links attaching, replacing each other and
detaching, and what it replays from which `agent_seq`.

Also:

- The host listens for shutdown signals from the start. Before, it
listened only inside its select loops, so a SIGTERM (`fctl host
restart`, `fctl host uninstall`) that arrived while the daemon awaited
something else was lost, and launchd killed the host 20 s later.
- A host that starts while its driver doesn't answer waits to connect
until it can list its sandboxes. Connecting with an empty list would
make the fleet stop sessions whose agents may still run.
- A stop now cancels a sandbox that is still being prepared ("Stopped
before it started").
- fctl-agent announces host connections in the order it accepts them.
Each connection used to announce itself from its own task, so a
short-lived probe (`fctl-agent serve` checking whether a supervisor
already runs, which the host now does before attaching again) could
replace the link the host had just opened. Older agents still recover:
the host attaches again when the link closes.

Not here: item 1 (the trigger; what the code tells about it is in #46),
item 6 (showing a stall on the session page, a control-plane and web
change), and item 7 and the disk questions, which wait on decisions.
Draining a host is #56.

## Compatibility

Hosts and agents only, with no protocol change. A new host works with
existing sandboxes, since every fctl-agent answers `Ping`. The
supervisor's logging and connection order come with a new fctl-agent,
which a sandbox gets when its agent next starts.

#39 also changes `ensure()` in `driver/container.rs` and `hello()` in
`daemon.rs`; whichever merges second needs a small rebase.

## How I verified it

- Unit tests: a hanging command is killed and its error says which
(`driver::tests`); link health and the waits between attempts
(`liveness::tests`).
- `cargo xtask check --quick`.
- Against the control plane of `cargo xtask dev`, with two hosts of this
build:
- **Docker hangs.** `FCTL_DOCKER_BIN` pointed at a stand-in that answers
like an empty Docker until a flag file makes every command hang.
- The runner stayed online, `last_seen_at` advancing every 15 s, while a
session's `docker inspect` hung until the host killed it after 60 s.
- Dropping the control connection (disabling and re-enabling the runner)
got the host registered again 12 ms after it reconnected. The old
`hello()` would have waited on `docker version` indefinitely.
- SIGTERM ended the host in 0.1 s while a session's `docker run` hung.
- A host started while Docker hung logged the commands that timed out,
and connected 10 s after Docker answered again.
- A new session failed after a minute, with the `docker inspect` timeout
as its reason. One stopped during a hung prepare ended "Stopped before
it started".
- **A frozen agent.** On the process driver, I stopped a session's
fctl-agent with SIGSTOP and sent the session a message. The host dropped
the link after a minute of silence, and the next attach timed out on its
hello. Once the agent resumed, the host attached again, the journal
replayed from the last acknowledged event, and the message was answered
once.

Refs #46

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

---------

Co-authored-by: fctl-sh[bot] <fctl-sh[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Brings #39 (publishing the sandbox image and pulling it on hosts) up to
main: #48, #50 and #52. The conflicts in the release workflow's header,
the release skill and docs/development.md keep both sides: #50's Build and
Publish jobs, and #39's Image workflow started after the release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fctl-sh Bot and others added 3 commits October 18, 2026 11:39
…pository

The default sandbox image moves to hyperstructure/sandbox, a public
repository whose Image workflow publishes it as
ghcr.io/hyperstructure/sandbox, with the tags #39 gave it: one per fctl
release, plus <major>.<minor> and latest. Hosts on the default image pull
<that name>:<their version> and fall back to :latest; fctl-agent:latest in
host.toml or a project still means the default.

images/agent leaves this repository. `fctl host image build` downloads an
archive of the sandbox's main branch and builds its Dockerfile with the
archive's root as the context, or gives the Lima VM its provision.sh, so a
host builds what is published. `--source DIR` builds from a checkout
instead, to try changes to the image with fctl. The process driver says
there is nothing to build before downloading anything.

The test that kept provision.sh's Rust in step with rust-toolchain.toml
goes with the file; AGENTS.md here and in the sandbox say to bump both
together. A sandbox without fctl now points at the default image instead
of `fctl host image build`, and the web app's image placeholder names the
new image. flate2 and tar become workspace dependencies, shared by the CLI
and the host.

Refs #2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Image workflow moves to hyperstructure/sandbox with the image, and
the Release workflow no longer starts it: its token can't start
workflows in another repository. The sandbox's workflow looks for new
releases on fctl.sh every hour instead.

Refs #2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Sandbox images page, the architecture, the CLI, development,
self-hosting and security pages, AGENTS.md and the release and issues
skills say that the default image is ghcr.io/hyperstructure/sandbox,
built and published by the public hyperstructure/sandbox repository
within the hour after each release, and that `fctl host image build`
builds its main branch (or a checkout, with --source).

ADR 0012 records the decision: the image needs to be public and has a
cadence of its own, so it lives in its own repository, connected to fctl
through fctl.sh. It amends 0005. (0011 is taken by #55.)

Refs #2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fctl-sh fctl-sh Bot changed the title feat: publish the sandbox image to GHCR and pull it on hosts feat(host): pull the sandbox image from GHCR instead of building it Oct 18, 2026
Brings in #54, #57 and #58. The conflicts are #57's against #39's:

- The daemon starts by retrying until the runtime lists the sandboxes
  (#57); the background pull of the host's image (#39) starts after
  that, once the runtime answers.
- The hello keeps #57's sandbox limits and #39's resolved image.
- session.rs imports both sides' names.

With #57's timeouts, a `run` that times out no longer counts as a
missing image: it fails at once instead of trying a pull that would hang
on the same runtime for up to 30 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kalaspuff
kalaspuff merged commit f239721 into main Oct 18, 2026
5 checks passed
@kalaspuff
kalaspuff deleted the fctl/14-implement-https-github-com-hyperstructur branch October 18, 2026 12:03
fctl-sh Bot added a commit to hyperstructure/fctl-site that referenced this pull request Oct 18, 2026
The build renders fctl's docs at fctl-docs.ref, and failed on the
Sandbox images page: its file arrives with hyperstructure/fctl#39, which
isn't merged, so the pin can't have it yet. That forced this pull
request to wait for fctl's.

A PAGES entry can now name the fctl pull request that adds its file
(`pending`). While the docs at the pin lack the file, the page is left
out of the build, the navigation and the links between docs, and the log
says so; once the pin has it, it renders and the log says `pending` can
go. Every other page must still be there, so a doc renamed or removed in
fctl still fails the build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fctl-sh Bot added a commit to hyperstructure/fctl-site that referenced this pull request Oct 18, 2026
hyperstructure/fctl#39 is merged and fctl's CI has pinned these docs
past it, so docs/sandbox-images.md is there and the page needs no
special case. This reverts commit 7ccfa1b.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kalaspuff pushed a commit to hyperstructure/fctl-site that referenced this pull request Oct 18, 2026
hyperstructure/fctl#39 (merged) makes fctl hosts pull the default
sandbox image instead of building it. The image now lives in its own
public repository, hyperstructure/sandbox (set up in
hyperstructure/sandbox#1), which publishes it as
`ghcr.io/hyperstructure/sandbox`. This adds fctl#39's new docs page and
keeps the privacy policy true.

## What changed

- **Docs:** `docs/sandbox-images.md` joins `PAGES`, after Self-hosting,
at `/docs/sandbox-images/`. It describes the default image (tags,
labels, rebuilds), how hosts get it, and the layout images built `FROM`
it rely on, and links to hyperstructure/sandbox for the versions.
- **Privacy policy:** "When you visit fctl.sh" said that the install
script and `fctl host` download releases from fctl.sh and that, beyond
that, agents talk only to Anthropic and GitHub. It now also says that
`fctl host` pulls the default sandbox image from GitHub's container
registry, ghcr.io, and that `fctl host image build` downloads the
image's sources from GitHub instead, so GitHub sees those requests.
`updated` is 2026-10-18.
- `main` is merged in, with fctl's CI pin to
hyperstructure/fctl@f239721, the merge of fctl#39, which adds
`docs/sandbox-images.md`. Before that pin, the build failed: it renders
fctl's docs at the pinned commit, which didn't have the page.

## Verification

- `npm run build` at the pin, as CI runs it, renders 8 docs pages and 2
legal pages. Links from the overview, Architecture, Security and
Development point at `/docs/sandbox-images/` and at
https://github.com/hyperstructure/sandbox, and the new page's text names
`ghcr.io/hyperstructure/sandbox`. CI's build passes.
- `npm run check` passes.

## Screenshots

At 8c5d100, taken with Playwright from a local build
(`FCTL_DOCS_DIR=<fctl#39's checkout> npm run build`, served as static
files), in both themes and at 390 px. Sessions can't attach them
(hyperstructure/fctl#40), so here is what they show: "Sandbox images"
sits in the docs navigation between Self-hosting and Architecture and is
highlighted on its own page. The page's two tables (tags, labels), its
code blocks (a `docker image inspect` command, a Dockerfile) and its
lists render like the other docs pages, with nothing overflowing at
phone width. The legal pages change only in text. The page has new text
since (the new image name and repository), in the same structure.

Refs hyperstructure/fctl#2

Sessions: https://app.fctl.sh/s/ses_01M56C4589JCHJ2W8TXC8V2S4P, and
https://app.fctl.sh/s/ses_01M57B991V0YYBRJFN0WKEW118 for the move 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>
fctl-sh Bot added a commit that referenced this pull request Oct 18, 2026
main added ADR 0012 (#39) and left 0011 for this branch's record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.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