Skip to content

feat: add the sandbox image, its publishing workflow and docs - #1

Merged
kalaspuff merged 5 commits into
mainfrom
fctl/25-please-continue-on-https-github-com-hype
Oct 18, 2026
Merged

kalaspuff merged 5 commits into
mainfrom
fctl/25-please-continue-on-https-github-com-hype

Conversation

@fctl-sh

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

Copy link
Copy Markdown
Contributor

This sets up the repository: the default sandbox image for fctl sessions moves here from hyperstructure/fctl (its images/agent, as hyperstructure/fctl#39 left it), with the workflow that publishes it to ghcr.io/hyperstructure/sandbox for arm64 and amd64, and what people and agents need to change it. hyperstructure/fctl#39 makes fctl hosts pull the image from there and removes the image from fctl.

What's here

  • The image: Dockerfile, provision.sh (which also provisions fctl's Lima VM) and check.sh (the layout fctl and images built FROM this one rely on: the agent user, sudo, the PATH, the tools), unchanged from fctl but for their comments and one ShellCheck note. They sit at the root, where fctl host image build finds them in an archive of main. .dockerignore sends only provision.sh to the build.

  • Image workflow: builds each architecture on a native runner (ubuntu-24.04, ubuntu-24.04-arm), pushes it by digest, checks it with check.sh and tags both as one image: :<fctl version> for every fctl release, plus :<major>.<minor> and :latest when that release is the newest they cover. Each image is main with that release of fctl (from fctl.sh) and the newest Claude Code, labelled with both versions, the commit and the date. Only main publishes:

    • every hour, the newest fctl release's image when there is none yet or it predates the last change to the image (fctl's Release workflow can't start workflows here, so this is what notices a release);
    • on a push to main that changes the image, at once;
    • every day at 05:41 UTC, also when Claude Code has a newer release than the image, or the image is a week old;
    • by hand: gh workflow run image.yml (-f version=0.3.0, -f force=true).

    Pull requests that change the image build and check both architectures without publishing.

  • Lint (ShellCheck, and actionlint at a pinned, checksummed version) and Commits (pull request titles are Conventional Commits, with fctl's rules). Dependabot updates the actions once a month.

  • README.md for people who use the image: what's inside, the tags and when they're rebuilt, the labels, the layout, building on it and building it, the licenses. AGENTS.md (and CLAUDE.md) for agents and people who change it: what fctl relies on, the conventions, the flow. Skills: change-the-image, publish and commit. SECURITY.md asks for private reports. MIT, like fctl.

For a person

  1. Merge this. The Image workflow on main then publishes ghcr.io/hyperstructure/sandbox:0.3.0, :0.3 and :latest (fctl 0.3.0 is the newest release), which creates the package.

  2. Make the package public: the organization's Packages → sandbox → Package settings → Change visibility. A package starts private even when its repository is public, and fctl hosts pull without signing in. The organization's settings must allow public packages.

  3. Make the repository public and apply its settings. Until it's public, the hourly check spends the organization's Actions minutes for private repositories (a minute per run, about 12 hours a month); in public repositories they're free. The session's token can't change settings, so here they are, as on hyperstructure/fctl where they apply (squash merges with the title and description, branches deleted on merge):

    R=hyperstructure/sandbox
    gh repo edit $R --visibility public --accept-visibility-change-consequences
    gh repo edit $R \
      --description "The sandbox fctl's agents run in: Debian with Claude Code, git, the GitHub CLI, Python, Rust, Node.js, TypeScript and Bun, for arm64 and amd64." \
      --homepage https://fctl.sh \
      --add-topic fctl,claude-code,sandbox,docker,container-image,ai-agents \
      --enable-issues --enable-wiki=false --enable-projects=false --enable-discussions=false \
      --enable-squash-merge --enable-merge-commit=false --enable-rebase-merge=false \
      --squash-merge-commit-message pr-title-description \
      --delete-branch-on-merge --allow-update-branch
    # Security: private vulnerability reports (SECURITY.md asks for them), Dependabot alerts and fixes, secret scanning.
    gh api -X PUT repos/$R/private-vulnerability-reporting
    gh api -X PUT repos/$R/vulnerability-alerts
    gh api -X PUT repos/$R/automated-security-fixes
    gh repo edit $R --enable-secret-scanning --enable-secret-scanning-push-protection
    # Workflows get a read-only token unless they ask for more (the Image workflow asks for packages: write).
    gh api -X PUT repos/$R/actions/permissions/workflow -f default_workflow_permissions=read -F can_approve_pull_request_reviews=false

    Optionally, protect main. Rulesets need the repository to be public on the organization's plan. This one requires pull requests (squash only, no approvals required, so a person can merge their own), the Lint and Commits checks (not Image, which runs only when the image changes), and no force pushes or deletion; repository admins can bypass it:

    gh api repos/$R/rulesets --input - <<'JSON'
    {"name":"main","target":"branch","enforcement":"active","conditions":{"ref_name":{"include":["~DEFAULT_BRANCH"],"exclude":[]}},"bypass_actors":[{"actor_id":5,"actor_type":"RepositoryRole","bypass_mode":"always"}],"rules":[{"type":"deletion"},{"type":"non_fast_forward"},{"type":"pull_request","parameters":{"required_approving_review_count":0,"dismiss_stale_reviews_on_push":false,"require_code_owner_review":false,"require_last_push_approval":false,"required_review_thread_resolution":false,"allowed_merge_methods":["squash"]}},{"type":"required_status_checks","parameters":{"strict_required_status_checks_policy":false,"required_status_checks":[{"context":"Conventional Commits"},{"context":"ShellCheck and actionlint"}]}}]}
    JSON
  4. Then merge feat(host): pull the sandbox image from GHCR instead of building it fctl#39. Hosts of the release after 0.3.0 pull their version's tag, which this repository publishes within the hour after the release.

GitHub turns off scheduled workflows in public repositories after 60 days without activity, warning by email first; gh workflow enable image.yml -R hyperstructure/sandbox turns the Image workflow back on (the publish skill).

Verification

  • This pull request's Image run: 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). Lint and Commits pass.
  • The 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), an image built from the last change (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, as 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).
  • The title check passes feat: add PostgreSQL's client, fix(image): put Bun on login shells' PATH (#12), ci(deps): bump the actions group with 3 updates (Dependabot's), feat!: … and build(deps): …, and rejects a title without a type, an unknown type, a full stop, 76 characters, an empty description and Feat:.
  • With fctl: fctl host image build from feat(host): pull the sandbox image from GHCR instead of building it fctl#39, with a stand-in docker, builds this checkout (--source) and a git archive of it served like GitHub's archive of main.
  • Not verified here: pushing to GHCR with the workflow's token, creating the package and its visibility. They need packages: write on main, so merging this is their test.

Refs hyperstructure/fctl#2

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

fctl-sh Bot and others added 4 commits October 18, 2026 11:31
The default sandbox for fctl sessions moves here from images/agent in
hyperstructure/fctl, as hyperstructure/fctl#39 left it: Debian bookworm
with git, gh, build tools, Python and uv, Rust, Node.js, TypeScript, Bun,
Claude Code and fctl, run as the agent user. provision.sh also provisions
fctl's Lima VM; check.sh checks a built image for the layout fctl and
images built FROM it rely on.

The Dockerfile, provision.sh and the build context sit at the root, where
`fctl host image build` finds them in an archive of main. check.sh checks
the workspace's existence and writability separately, which ShellCheck
prefers. The scripts are MIT licensed, like fctl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Image workflow builds the image for amd64 and arm64 on native
runners, checks each build with check.sh and tags both as
ghcr.io/hyperstructure/sandbox:<fctl version>, plus :<major>.<minor> and
:latest when that release is the newest they cover. It publishes from
main only:

- every hour, the newest fctl release's image when there is none yet, or
  when it predates the last change to the image (read from its revision
  annotation); a push to main that changes the image does the same at
  once;
- every day, also when Claude Code has a newer release than the image or
  the image is a week old;
- by hand, for any release, with force to rebuild a current one.

Pull requests that change the image build and check it without
publishing. fctl's Release workflow can't start workflows here, so the
hourly run is what notices a release; fctl.sh/releases is the list of
releases, as fctl's tags were before.

Lint runs ShellCheck and a pinned, checksummed actionlint. Commits checks
that pull request titles are Conventional Commits, with fctl's rules.
Dependabot updates the actions once a month in one pull request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
README.md says what's inside, the tags and when they're rebuilt, the
labels, the layout images built FROM it rely on, how to build on it and
build it, and the licenses. AGENTS.md and the skills (commit,
change-the-image, publish) tell agents and people what fctl relies on,
how to change the image and verify it without Docker, and how publishing
works and fails. SECURITY.md asks for private reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kalaspuff
kalaspuff merged commit fc5739a into main Oct 18, 2026
6 checks passed
kalaspuff pushed a commit to hyperstructure/fctl 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 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>
@kalaspuff
kalaspuff deleted the fctl/25-please-continue-on-https-github-com-hype branch October 18, 2026 13:40
kalaspuff added a commit that referenced this pull request Oct 18, 2026
GitHub hasn't started this workflow's schedule since #1 added it: no
scheduled run at 12:11, 13:11 or 14:11 UTC on 2026-10-18, while manual
runs work. The cron lines were last changed by fctl-sh[bot]. A person's
commit that changes the cron line makes them the schedule's actor, and a
push to main resyncs schedules.
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