diff --git a/.claude/skills/change-the-image/SKILL.md b/.claude/skills/change-the-image/SKILL.md new file mode 100644 index 0000000..a748122 --- /dev/null +++ b/.claude/skills/change-the-image/SKILL.md @@ -0,0 +1,94 @@ +--- +name: change-the-image +description: Add, upgrade or remove a tool in the sandbox image, or change its layout. Covers where versions live, both architectures, the Lima VM that provision.sh also provisions, what fctl and derived images rely on, the image's size, and verifying a change with a local build or the pull request's Image workflow. Use for any change to Dockerfile, provision.sh or check.sh. +--- + +# Changing the image + +- `provision.sh` installs everything, as root; the Dockerfile only runs it + and sets the user and the PATH. The versions are at the top, as + `NAME="${NAME:-default}"`, so a build can override one. +- `check.sh` is the layout fctl and images built `FROM` this one rely on, + checked in every build. +- README.md lists the tools with their versions, and the layout. + +## Adding or upgrading a tool + +1. **Install it in `provision.sh`.** Prefer Debian's package: add it to the + `apt-get install` list. Otherwise use the project's own installer or + release archive, with the version pinned at the top of the script, and + check the download against the checksum the project publishes, when it + publishes one. A toolchain agents extend (more versions, targets, global + tools) goes in the agent's home through `as_agent`, like rustup and Bun; + anything else system-wide. +2. **Make it work from the PATH alone.** fctl-agent passes Claude the PATH, + HOME and the locale, nothing else from the image. A tool that needs an + environment variable gets a wrapper or a config file instead. A new PATH + entry is a layout change (below). +3. **Check for it.** Add the command to `check.sh`'s list of tools, and to + the `--version` runs when it could be there but broken (a proxy without + its toolchain). +4. **List it** in README.md's "What's inside", with its version. +5. **Say what it costs.** The Image workflow's summary lists each + architecture's size; put the difference in the pull request. + +An upgrade changes the version at the top of `provision.sh` and in README.md. +Rust follows fctl's `rust-toolchain.toml`; Claude Code and fctl aren't pinned +here (the workflow passes the versions it builds with). + +Removing a tool breaks images and sessions that use it: a breaking change +(the `commit` skill) with a reason. + +## What bites + +- **Two architectures.** Every download works on amd64 and arm64: pick the + file with `dpkg --print-architecture` (`amd64`, `arm64`) or `uname -m` + (`x86_64`, `aarch64`). +- **The Lima VM.** fctl's Lima driver runs `provision.sh` in a Debian 12 VM + (Lima's cloud image, not `bookworm-slim`) as root, with `AGENT_USER` set + to the VM's user, who already exists and may not be uid 1000. Use + `$AGENT_USER` and `$AGENT_HOME`, never `agent` or `/home/agent`, and don't + rely on what only the Dockerfile sets. +- **Login shells.** Setup scripts and the Lima driver run `bash -l`, which + reads `/etc/profile.d/fctl.sh`. Keep its PATH and the Dockerfile's + `ENV PATH` the same. +- **apt's lists** are removed after the first installs; a later + `apt-get install` needs its own `apt-get update` and cleanup. +- **Python is externally managed.** No `pip install` into the system: use + `uv tool install` (as the agent) or a Debian package. +- **No auto-updates and no daemons.** fctl turns off Claude Code's + auto-updater, and sessions start only fctl-agent; a tool that expects a + service running won't find one. + +## Changing the layout + +The user, its home, `~/workspace`, `/opt/fctl/bin` and the PATH are an +interface: installed fctl hosts and images built `FROM` this one rely on +them. Look for a way that keeps them first. If there is none: + +1. Make it a breaking change (`feat!:` with a `BREAKING CHANGE:` paragraph). +2. Change `check.sh` and README.md's Layout with it. +3. Change fctl's Sandbox images page (`docs/sandbox-images.md` in + hyperstructure/fctl) to match, and fctl itself when hosts must adapt, + released before this merges. An fctl session's token covers only its own + repository: name what hyperstructure/fctl needs, and tell the person. + +The newest fctl release's tag is rebuilt from `main`, so its hosts get the +change the next time they pull the image. + +## Verifying + +- **With Docker:** `docker build -t sandbox:dev .`, then + `docker run --rm -i sandbox:dev sh -s < check.sh`, and try the tool: + `docker run --rm sandbox:dev bash -lc 'psql --version'`. Add + `--platform linux/amd64` (or `arm64`) to try the other architecture under + emulation, slowly. +- **In an fctl session** (no Docker): open the pull request. The Image + workflow builds both architectures with the latest fctl and Claude Code + and runs `check.sh` in each: `gh pr checks N --watch --fail-fast`, then + the run's summary for the sizes. +- **With fctl:** on a host, `docker build -t sandbox:dev .` and set it as a + project's image (Settings → Sandbox → Image), or + `fctl host image build --source .` from a checkout, which builds it under + the host's image name (with Lima: provisions the base VM). Then start a + session and use the tool. diff --git a/.claude/skills/commit/SKILL.md b/.claude/skills/commit/SKILL.md new file mode 100644 index 0000000..76b61f1 --- /dev/null +++ b/.claude/skills/commit/SKILL.md @@ -0,0 +1,107 @@ +--- +name: commit +description: Commit and open pull requests in hyperstructure/sandbox. Covers Conventional Commit titles, breaking changes, what goes in the description, branch names, waiting for the checks, answering reviews, and what happens after the merge. Use when committing, naming a branch, writing a pull request title or description, or getting a change merged. +--- + +# Commits and pull requests + +Every change reaches `main` as one squash-merged pull request. Its **title +becomes the commit subject** and its description the commit body. Merging a +change to the image publishes it (the `publish` skill), so `main` is always +what's published. + +## The title + +`type(scope): what changed`, at most 72 characters, no full stop, in the +imperative. The Commits workflow checks every title, and again when you +edit it. + +| Type | For | +|---|---| +| `feat` | a tool the image didn't have, or a new major version of one | +| `fix` | something broken in the image or in publishing it | +| `perf` | a smaller or faster image with the same tools | +| `docs` | documentation only | +| `build` | other version upgrades | +| `refactor`, `test`, `ci`, `chore`, `revert` | restructuring; checks; workflows; upkeep; reverts | + +The scope is optional: `image`, `ci`, `deps`, `docs`. + +- `feat: add PostgreSQL's client` +- `build: upgrade Bun to 1.5.0` +- `fix: put Bun on login shells' PATH` +- `ci: rebuild the image when Claude Code moves on` + +A **breaking change** changes what fctl or images built `FROM` this one rely +on (the layout in README.md, a tool removed, a file fctl downloads). It gets +a `!` and a `BREAKING CHANGE:` paragraph in the description that says what +people must do: `feat!: drop Node.js 20`. Check with AGENTS.md whether fctl +must change first. + +## The description + +Plain prose for someone reading `git log` in a year: what was missing or +wrong, what changed and why this way, what it adds to the image's size, and +how you verified it (the Image workflow's run, `check.sh`, a local build). +Link the issue: `Fixes #12` only when the pull request does everything it +asks, `Refs #12` otherwise. + +The description ends with the content: no "Generated with …" footers. A link +to the fctl session that made the change is welcome. + +## The flow + +1. Branch from an up-to-date `main`: `/` (`feat/postgres`), or + `fctl/-` when an fctl session makes the branch. +2. Commit as you go; the commits are squashed, so the title is what lasts. +3. Run `shellcheck provision.sh check.sh` and `actionlint` if you have them; + the Lint workflow runs both anyway. +4. Open the pull request with a conventional title and a description. One + change per pull request. +5. Wait for the checks in the same turn and fix failures (below). +6. A person merges it (squash) when the checks pass, unless they asked you + to merge it yourself. + +Never push to `main` directly, and never commit secrets: the repository and +the image are public. + +## The checks + +```sh +gh pr checks N --watch --fail-fast +``` + +- **Image** builds the pull request's image for amd64 and arm64 with the + latest fctl and Claude Code and runs `check.sh` in each. It runs when the + image's files or the workflow change, and takes a few minutes. +- **Lint** runs ShellCheck and actionlint. +- **Commits** checks the title. When only it fails, fix the title: + `gh pr edit N --title "fix: …"`. + +Read a failure with `gh run view ID --log-failed`. Something that looks like +infrastructure (a timeout, a registry or download error) gets one rerun +(`gh run rerun ID --failed`); if it fails again, treat it as real. Don't ask +anyone to merge a pull request with failing checks, and don't weaken a check +to make it pass. + +## Reviews + +Read the conversation and the comments on lines: + +```sh +gh pr view N --comments +gh api repos/hyperstructure/sandbox/pulls/N/comments --jq '.[] | {id, path, line, user: .user.login, body}' +``` + +Answer every comment in its own thread: fix it, push, and reply with what +changed, or say plainly why not. Leave resolving threads to the reviewer. +After changes, update the description so it still says what the pull +request does: it becomes the commit message. + +## After the merge + +- Don't push to the merged branch; follow-ups go in a new pull request from + the updated `main`. +- A change to the image starts the Image workflow on `main`. Check that it + published (the `publish` skill), and say so where the change was asked + for. diff --git a/.claude/skills/publish/SKILL.md b/.claude/skills/publish/SKILL.md new file mode 100644 index 0000000..e3df382 --- /dev/null +++ b/.claude/skills/publish/SKILL.md @@ -0,0 +1,105 @@ +--- +name: publish +description: How the sandbox image reaches ghcr.io/hyperstructure/sandbox, and how to rebuild or repair it. Covers what starts the Image workflow (fctl releases, merges, Claude Code releases, the weekly rebuild), its tags and labels, running it by hand, checking a published image with or without Docker, and what to do when a run fails or the schedule stops. Use when asked to publish, rebuild or check the image, after merging a change to it, or when an fctl release has no image. +--- + +# Publishing the image + +The Image workflow (`.github/workflows/image.yml`) publishes +`ghcr.io/hyperstructure/sandbox` from `main` only. Its first job, "What to +build", decides whether there is anything to do and says why in the run's +summary; then each architecture builds on a runner of its own (amd64 on +`ubuntu-24.04`, arm64 on `ubuntu-24.04-arm`), is pushed by digest and +checked with `check.sh`, and the last job tags the two as one image. + +## When it runs + +| Trigger | Builds the newest fctl release's image when | +|---|---| +| A push to `main` that changes the image | the published one predates the change | +| Every hour (minute 11) | there is none yet (a new fctl release), or it predates a change | +| Every day (05:41 UTC) | as hourly, or Claude Code has a newer release, or the image is a week old | +| `gh workflow run image.yml` | as daily; `-f version=…` for another release, `-f force=true` always | +| A pull request that changes the image | always, with the latest fctl, without publishing | + +Every build installs that fctl release from https://fctl.sh/releases and +the newest Claude Code (https://downloads.claude.ai), and passes both +versions to `check.sh`. + +## Tags and labels + +- `` (`0.4.0`): the release's image. Hosts of that version pull it. +- `.` (`0.4`): moves only when the build is the newest patch + of its minor. fctl's releases count up one patch at a time and fctl.sh + keeps them all, so the workflow asks fctl.sh for the next patch. +- `latest`: moves only for the newest release. + +The image and its index carry `org.opencontainers.image.version` (fctl's +version), `sh.fctl.claude-code.version`, `org.opencontainers.image.revision` +(the commit built) and `org.opencontainers.image.created`. The workflow reads +them back to decide whether an image is current. + +## Running it by hand + +It takes write access to Actions on this repository (people have it; fctl +sessions don't, so ask a person): + +```sh +gh workflow run image.yml -R hyperstructure/sandbox # the newest release, if needed +gh workflow run image.yml -R hyperstructure/sandbox -f version=0.3.0 # another release +gh workflow run image.yml -R hyperstructure/sandbox -f force=true # even when current +gh run list -R hyperstructure/sandbox --workflow image.yml --limit 3 +gh run watch -R hyperstructure/sandbox ID --exit-status +``` + +## Checking what's published + +With Docker: + +```sh +docker buildx imagetools inspect ghcr.io/hyperstructure/sandbox:0.3.0 # linux/amd64 and linux/arm64 +docker buildx imagetools inspect --raw ghcr.io/hyperstructure/sandbox:0.3.0 | jq .annotations +docker run --rm -i ghcr.io/hyperstructure/sandbox:0.3.0 sh -s < check.sh +``` + +Without Docker (an fctl session), from the registry's API: + +```sh +token=$(curl -fsS "https://ghcr.io/token?scope=repository:hyperstructure/sandbox:pull" | jq -r .token) +curl -fsS -H "Authorization: Bearer $token" \ + -H "Accept: application/vnd.oci.image.index.v1+json" \ + https://ghcr.io/v2/hyperstructure/sandbox/manifests/latest | + jq '{annotations, platforms: [.manifests[].platform | "\(.os)/\(.architecture)"]}' +curl -fsS -H "Authorization: Bearer $token" https://ghcr.io/v2/hyperstructure/sandbox/tags/list +``` + +## When something fails + +Publishing happens only after both architectures passed `check.sh`, so a +failed run leaves the published tags as they were (and an untagged digest +behind, which is harmless). + +- **"What to build" fails**: fctl.sh or downloads.claude.ai didn't answer + as expected; the error says what came back. Rerun it later. +- **`check.sh` says the Claude Code version differs**: a Claude Code release + landed between planning and building. Rerun the run. +- **`check.sh` fails otherwise, or the build does**: the image is broken, + often by a change upstream (an installer or a package moved). Fix it in a + pull request (the `change-the-image` skill); its own Image run shows the + fix works. +- **The push is denied**: the package must give this repository write + access (the package's settings → Manage Actions access). It has it when + this workflow created the package. +- **Hosts can't pull it** ("unauthorized", "denied"): the package must be + public. A package starts private when the workflow first creates it; a + person makes it public in the package's settings (Danger zone → Change + visibility), and the organization must allow public packages. +- **The schedule stopped**: GitHub turns off scheduled workflows in a public + repository after 60 days without activity, and emails a warning first. + A person turns it back on with + `gh workflow enable image.yml -R hyperstructure/sandbox`, then runs it by + hand. Meanwhile a new fctl release has no image of its own, and its hosts + start sessions from `latest`. + +Never delete a published version that has a tag: hosts pull by tag, and a +broken one is fixed by a rebuild. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..704c5fe --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +# The build needs only provision.sh, which the Dockerfile copies in. +* +!provision.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e608fe4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +# Keeps the workflows' actions current: one pull request a month for all of +# them, titled as a Conventional Commit (ci(deps): …). +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + groups: + actions: + patterns: ["*"] + commit-message: + prefix: ci + include: scope diff --git a/.github/workflows/commits.yml b/.github/workflows/commits.yml new file mode 100644 index 0000000..b342ca0 --- /dev/null +++ b/.github/workflows/commits.yml @@ -0,0 +1,37 @@ +# Pull request titles must be Conventional Commits: they become the +# squash-merged commits on main (.claude/skills/commit/SKILL.md). The rules +# are fctl's (xtask/src/release.rs in hyperstructure/fctl); keep them in step. +# Separate from the other checks, so that editing a title reruns only this. +name: Commits + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +concurrency: + group: commits-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + conventional: + name: Conventional Commits + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: The pull request title (it becomes the commit on main) + env: + TITLE: ${{ github.event.pull_request.title }} + run: | + types="feat fix perf docs refactor test build ci chore revert" + fail() { echo "::error::$1"; exit 1; } + title=$(sed -E 's/ \(#[0-9]+\)$//' <<<"$TITLE") + shape='^([a-z0-9/-]+)(\([a-z0-9/-]+\))?!?: (.*[^[:space:]].*)$' + [[ $title =~ $shape ]] || fail "\`$title\` is not \`type(scope): what changed\`; types: ${types// /, }" + kind=${BASH_REMATCH[1]} text=${BASH_REMATCH[3]} + [[ " $types " == *" $kind "* ]] || fail "\`$kind\` is not a commit type; use one of: ${types// /, }" + [[ $text != *. ]] || fail "drop the full stop at the end" + (( ${#title} <= 72 )) || fail "keep it to 72 characters (${#title} now); details go in the body" + echo "\`$title\` is a Conventional Commit." >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml new file mode 100644 index 0000000..2ea9998 --- /dev/null +++ b/.github/workflows/image.yml @@ -0,0 +1,294 @@ +# Builds the image for arm64 and amd64, checks it with check.sh and publishes +# it as ghcr.io/hyperstructure/sandbox:, plus :. +# and :latest when that release is the newest they cover. An image is this +# repository's main with a release of fctl and the newest Claude Code. +# +# - Every hour it builds the latest fctl release's image when there is none +# yet, or when the image predates the last change to it on main. A push to +# main that changes the image does the same at once. +# - Every day it also rebuilds it when Claude Code has a newer release than +# the one inside, or when the image is a week old (for Debian's updates): +# fctl turns off Claude Code's auto-updater, so an image's Claude Code is as +# old as the image. +# - `gh workflow run image.yml` does what the daily run does; +# `-f version=0.3.0` builds another release's image, `-f force=true` even +# when it is current. +# - Pull requests that change the image build and check it without +# publishing. +# +# Each architecture builds on its own runner and is pushed by digest, without +# a tag, and checked; the last job tags the two as one image. Only main +# publishes. +name: Image + +on: + pull_request: + paths: + - Dockerfile + - provision.sh + - check.sh + - .dockerignore + - .github/workflows/image.yml + push: + branches: [main] + paths: + - Dockerfile + - provision.sh + - check.sh + - .dockerignore + - .github/workflows/image.yml + schedule: + - cron: "11 * * * *" + - cron: "41 5 * * *" + workflow_dispatch: + inputs: + version: + description: The fctl release to build, like 0.3.0 (default the latest) + required: false + force: + description: Build even when the published image is current + type: boolean + default: false + +permissions: + contents: read + +# One publishing run at a time, each planning from what the one before it +# published. A pull request's new commits cancel its older runs. +concurrency: + group: image-${{ github.event_name == 'pull_request' && github.ref || 'publish' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + IMAGE: ghcr.io/hyperstructure/sandbox + DESCRIPTION: The default sandbox for fctl sessions + SOURCE: https://github.com/hyperstructure/sandbox + +jobs: + plan: + name: What to build + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + packages: read + outputs: + version: ${{ steps.plan.outputs.version }} + claude: ${{ steps.plan.outputs.claude }} + tags: ${{ steps.plan.outputs.tags }} + created: ${{ steps.plan.outputs.created }} + build: ${{ steps.plan.outputs.build }} + push: ${{ steps.plan.outputs.push }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - name: Versions and tags + id: plan + env: + EVENT: ${{ github.event_name }} + SCHEDULE: ${{ github.event.schedule }} + VERSION: ${{ inputs.version }} + FORCE: ${{ inputs.force && 'yes' || '' }} + GH_TOKEN: ${{ github.token }} + run: | + semver='^[0-9]+\.[0-9]+\.[0-9]+$' + releases=https://fctl.sh/releases + latest=$(curl -fsS --retry 3 "$releases/latest" | tr -d '[:space:]') + [[ $latest =~ $semver ]] || { echo "::error::fctl.sh/releases/latest says '$latest'"; exit 1; } + # What https://claude.ai/install.sh installs; every architecture gets this one. + claude=$(curl -fsS --retry 3 https://downloads.claude.ai/claude-code-releases/latest | tr -d '[:space:]') + [[ $claude =~ $semver ]] || { echo "::error::downloads.claude.ai says the latest Claude Code is '$claude'"; exit 1; } + + version=${VERSION:-$latest} version=${version#v} + [[ $version =~ $semver ]] || { echo "::error::$version is not a version like 0.3.0"; exit 1; } + released() { curl -fs --retry 3 -o /dev/null "$releases/v$1/manifest.json"; } + released "$version" || { echo "::error::fctl $version is not on fctl.sh/releases"; exit 1; } + + # Only main publishes; pull requests and other branches build and check. + push=no + if [ "$EVENT" != pull_request ] && [ "$GITHUB_REF" = refs/heads/main ]; then push=yes; fi + + # fctl's releases count up one patch at a time and fctl.sh keeps them + # all, so this one is the newest of its minor unless the next patch is there. + IFS=. read -r major minor patch <<<"$version" + tags="$IMAGE:$version" + if ! released "$major.$minor.$((patch + 1))"; then tags="$tags $IMAGE:$major.$minor"; fi + if [ "$version" = "$latest" ]; then tags="$tags $IMAGE:latest"; fi + + what="fctl $version with Claude Code $claude" + build=yes why="it was started with force=true" + if [ "$push" = yes ] && [ -z "$FORCE" ]; then + echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin >/dev/null + if index=$(docker buildx imagetools inspect --raw "$IMAGE:$version" 2>/dev/null); then + have=$(jq -r '.annotations["sh.fctl.claude-code.version"] // ""' <<<"$index") + revision=$(jq -r '.annotations["org.opencontainers.image.revision"] // ""' <<<"$index") + built=$(jq -r '.annotations["org.opencontainers.image.created"] // "1970-01-01"' <<<"$index") + age=$(( ($(date +%s) - $(date -d "$built" +%s 2>/dev/null || echo 0)) / 86400 )) + # The image is current when it was built from the last commit that + # changed what goes into it, or from a later one. + changed=$(git log -1 --format=%H -- Dockerfile provision.sh .dockerignore .github/workflows/image.yml) + # Claude Code's and Debian's updates count once a day, and when + # started by hand. + daily=no + if [ "$SCHEDULE" = "41 5 * * *" ] || [ "$EVENT" = workflow_dispatch ]; then daily=yes; fi + if ! git merge-base --is-ancestor "$changed" "$revision" 2>/dev/null; then + why="$IMAGE:$version predates $(git log -1 --format='%h ("%s")' "$changed")" + elif [ "$daily" = yes ] && [ "$have" != "$claude" ]; then + why="$IMAGE:$version has Claude Code $have" + elif [ "$daily" = yes ] && [ "$age" -ge 7 ]; then + why="$IMAGE:$version is $age days old" + else + build=no why="$IMAGE:$version is current (Claude Code $have, built $age days ago from ${revision:0:7})" + fi + else + why="$IMAGE:$version isn't published yet" + fi + fi + + { + echo "version=$version" + echo "claude=$claude" + echo "tags=$tags" + echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "build=$build" + echo "push=$push" + } >> "$GITHUB_OUTPUT" + { + if [ "$build" = no ]; then + echo "Nothing to build: $why." + elif [ "$push" = yes ]; then + echo "Building $what for \`$tags\`: $why." + else + echo "Building $what from \`$GITHUB_REF\`, without publishing it." + fi + } >> "$GITHUB_STEP_SUMMARY" + + build: + name: Build (${{ matrix.arch }}) + needs: plan + if: needs.plan.outputs.build == 'yes' + strategy: + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + permissions: + contents: read + packages: write + env: + VERSION: ${{ needs.plan.outputs.version }} + CLAUDE: ${{ needs.plan.outputs.claude }} + CREATED: ${{ needs.plan.outputs.created }} + PUSH: ${{ needs.plan.outputs.push }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Build + id: build + env: + GH_TOKEN: ${{ github.token }} + ARCH: ${{ matrix.arch }} + run: | + docker buildx create --name sandbox --driver docker-container --use >/dev/null + if [ "$PUSH" = yes ]; then + echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin >/dev/null + output="type=image,name=$IMAGE,push-by-digest=true,name-canonical=true,push=true,oci-mediatypes=true" + else + output="type=docker,name=sandbox:check" + fi + docker buildx build . \ + --platform "linux/$ARCH" --pull --provenance=false \ + --build-arg "FCTL_VERSION=$VERSION" \ + --build-arg "CLAUDE_VERSION=$CLAUDE" \ + --label "org.opencontainers.image.title=sandbox" \ + --label "org.opencontainers.image.description=$DESCRIPTION" \ + --label "org.opencontainers.image.url=$SOURCE" \ + --label "org.opencontainers.image.source=$SOURCE" \ + --label "org.opencontainers.image.licenses=MIT" \ + --label "org.opencontainers.image.version=$VERSION" \ + --label "org.opencontainers.image.revision=$GITHUB_SHA" \ + --label "org.opencontainers.image.created=$CREATED" \ + --label "sh.fctl.version=$VERSION" \ + --label "sh.fctl.claude-code.version=$CLAUDE" \ + --output "$output" \ + --metadata-file "$RUNNER_TEMP/build.json" + if [ "$PUSH" = yes ]; then + digest=$(jq -r '."containerimage.digest"' "$RUNNER_TEMP/build.json") + echo "digest=$digest" >> "$GITHUB_OUTPUT" + echo "image=$IMAGE@$digest" >> "$GITHUB_OUTPUT" + else + echo "image=sandbox:check" >> "$GITHUB_OUTPUT" + fi + - name: Check it + env: + CHECK: ${{ steps.build.outputs.image }} + ARCH: ${{ matrix.arch }} + run: | + docker run --rm -i -e FCTL_VERSION="$VERSION" -e CLAUDE_VERSION="$CLAUDE" "$CHECK" sh -s < check.sh + docker image inspect --format '{{.Size}}' "$CHECK" | + awk -v arch="$ARCH" '{ printf "%s: %.2f GB\n", arch, $1 / 1e9 }' >> "$GITHUB_STEP_SUMMARY" + - name: Note its digest + if: needs.plan.outputs.push == 'yes' + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + mkdir -p "$RUNNER_TEMP/digests" + touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}" + - if: needs.plan.outputs.push == 'yes' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: digest-${{ matrix.arch }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + publish: + name: Publish + needs: [plan, build] + if: needs.plan.outputs.push == 'yes' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + packages: write + env: + VERSION: ${{ needs.plan.outputs.version }} + CLAUDE: ${{ needs.plan.outputs.claude }} + CREATED: ${{ needs.plan.outputs.created }} + TAGS: ${{ needs.plan.outputs.tags }} + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: digest-* + path: ${{ runner.temp }}/digests + merge-multiple: true + - name: Tag both architectures as one image + env: + GH_TOKEN: ${{ github.token }} + run: | + echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin >/dev/null + sources=() + for file in "$RUNNER_TEMP"/digests/*; do sources+=("$IMAGE@sha256:$(basename "$file")"); done + [ "${#sources[@]}" = 2 ] || { echo "::error::expected a digest for each architecture, got ${sources[*]}"; exit 1; } + tags=() + for tag in $TAGS; do tags+=(--tag "$tag"); done + # On the index too: GHCR shows these, and the next runs read the + # Claude Code version, the commit and the date from them. + docker buildx imagetools create "${tags[@]}" \ + --annotation "index:org.opencontainers.image.description=$DESCRIPTION" \ + --annotation "index:org.opencontainers.image.source=$SOURCE" \ + --annotation "index:org.opencontainers.image.licenses=MIT" \ + --annotation "index:org.opencontainers.image.version=$VERSION" \ + --annotation "index:org.opencontainers.image.revision=$GITHUB_SHA" \ + --annotation "index:org.opencontainers.image.created=$CREATED" \ + --annotation "index:sh.fctl.claude-code.version=$CLAUDE" \ + "${sources[@]}" + docker buildx imagetools inspect "$IMAGE:$VERSION" + { + echo "Published fctl $VERSION with Claude Code $CLAUDE as:" + for tag in $TAGS; do echo "- \`$tag\`"; done + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..1a67b2d --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,35 @@ +# ShellCheck for the scripts, actionlint (with ShellCheck) for the workflows. +name: Lint + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: lint-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + lint: + name: ShellCheck and actionlint + runs-on: ubuntu-24.04 + timeout-minutes: 10 + env: + ACTIONLINT_VERSION: 1.7.12 + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: ShellCheck + run: shellcheck --version && shellcheck provision.sh check.sh + - name: actionlint + run: | + archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + curl -fsSL --retry 3 -o "$RUNNER_TEMP/$archive" \ + "https://github.com/rhysd/actionlint/releases/download/v$ACTIONLINT_VERSION/$archive" + echo "$ACTIONLINT_SHA256 $RUNNER_TEMP/$archive" | sha256sum -c - + tar -xzf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP" actionlint + "$RUNNER_TEMP/actionlint" -color diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9b04366 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,113 @@ +# AGENTS.md + +You are working on **the sandbox image for [fctl](https://fctl.sh)**, a +software factory that runs Claude Code agents in sandboxes on people's +machines. Every fctl session runs in a container made from this image, +unless its project or host names another one. The Image workflow publishes +it as `ghcr.io/hyperstructure/sandbox`, for amd64 and arm64. + +This repository is public. fctl itself lives in hyperstructure/fctl, which +is private for now, so link to https://fctl.sh rather than to its files in +what people read here. + +| File | What it is | +|---|---| +| `Dockerfile` | The image: runs `provision.sh`, then sets the user and the PATH. | +| `provision.sh` | Installs everything, as root. The versions are at the top. | +| `check.sh` | Checks a built image for the layout fctl and derived images rely on. | +| `.github/workflows/image.yml` | Builds both architectures, checks them and publishes them. | +| `.github/workflows/lint.yml`, `commits.yml` | ShellCheck and actionlint; pull request titles. | +| `.claude/skills/` | How to change the image, publish it, and commit. | + +## Commands + +```sh +docker build -t sandbox:dev . # your architecture +docker build -t sandbox:dev --build-arg FCTL_VERSION=0.3.0 . # with another fctl +docker run --rm -i sandbox:dev sh -s < check.sh # check the layout +shellcheck provision.sh check.sh +actionlint # the workflows +``` + +fctl sessions have no Docker. There, a pull request's Image workflow is the +build: it builds amd64 and arm64 on runners of their own and runs `check.sh` +in each. Watch it with `gh pr checks N --watch --fail-fast`. + +## What fctl relies on + +Released fctl binaries depend on these, so a change breaks hosts that are +already installed. Change them only together with fctl, and say so in the +pull request. + +- **The layout** that `check.sh` checks and README.md describes: the `agent` + user (uid 1000, home `/home/agent`, sudo without a password), + `~/workspace`, `/opt/fctl/bin` owned by `agent`, and `~/.local/bin`, + `~/.cargo/bin`, `~/.bun/bin` and `/opt/fctl/bin` first on the PATH, both + in the image (`ENV PATH`) and in login shells (`/etc/profile.d/fctl.sh`). + fctl-agent passes Claude the PATH, HOME and the locale and nothing else + from the image, so every tool must work from those alone. +- **The files fctl downloads.** `fctl host image build` downloads `main` as + an archive and builds `Dockerfile` with the repository's root as the + context, passing `FCTL_VERSION` and `FCTL_DOWNLOAD` as build arguments. + With Lima, it copies `provision.sh` into a Debian 12 VM and runs it as + root with `AGENT_USER` set to the VM's user and the same variables. Keep + both files at the root, under these names, self-contained, and working on + Debian 12 with a user other than `agent`. +- **The tags**: `` for every fctl release, `.` + and `latest`. Hosts pull their own version's tag and fall back to + `latest`. +- **Subscriptions only.** fctl runs Claude Code on Claude subscriptions, + never on API billing. Never set `ANTHROPIC_*` variables, an + `apiKeyHelper`, or Bedrock, Vertex or Foundry switches in the image. +- **Nothing secret in the image.** It is public: no credentials, tokens or + personal settings, in files or in build arguments. + +A change to the layout is a breaking change (the `commit` skill). It needs +a matching change to fctl's Sandbox images page (`docs/sandbox-images.md` in +hyperstructure/fctl) and, when hosts must adapt, an fctl release first. + +## Conventions + +- Versions are pinned at the top of `provision.sh` and listed in README.md; + change both together. Claude Code and fctl are the exceptions: the + workflow passes the versions it builds with. +- Rust is the release fctl builds with (`rust-toolchain.toml` in + hyperstructure/fctl), so sessions working on fctl have nothing to + download. Bump the two together. +- Prefer Debian's packages; otherwise the project's own installer or + release archive, pinned to a version. Remove apt's lists and other caches + in the same step. +- Mind the size: hosts download the image for every fctl release. Say in + the pull request what a change adds; the Image workflow's summary lists + each architecture's size. +- `provision.sh` is bash with `set -euo pipefail`; `check.sh` is POSIX sh. + Both pass ShellCheck. +- Workflows pin actions to a commit SHA with the version in a comment, and + give each job only the permissions it needs. +- Copy is plain and specific, in sentence case. + +## Commits and pull requests + +- Changes reach `main` as squash-merged pull requests whose title is a + Conventional Commit (`feat: add PostgreSQL's client`); the Commits + workflow checks it. A person merges agents' pull requests unless they ask + the agent to. How: [.claude/skills/commit/SKILL.md](.claude/skills/commit/SKILL.md). +- Merging a change to the image publishes it within minutes, so `main` is + always what's published. Never push to `main` directly. +- Before starting on an issue, read it with its comments + (`gh issue view N --comments`). In a pull request, write `Fixes #N` only + when it does everything the issue asks; otherwise `Refs #N`. +- After opening a pull request, wait for its checks in the same turn and fix + failures. Nothing wakes an fctl session when CI finishes or someone + reviews. +- Answer every review comment in its thread: fix it, or say why not. + +## Skills + +- [change-the-image](.claude/skills/change-the-image/SKILL.md): adding, + upgrading or removing a tool, changing the layout, and verifying it. +- [publish](.claude/skills/publish/SKILL.md): when images are published, + rebuilding one by hand, checking what's published, and fixing a failed + run. +- [commit](.claude/skills/commit/SKILL.md): titles, descriptions, and + getting a pull request merged. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fb4da31 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# The default sandbox for fctl sessions: Debian with git, gh, build tools, +# Python and uv, Rust, Node.js, TypeScript, Bun, Claude Code and fctl +# (README.md lists the versions). Sessions run as the `agent` user. +# .github/workflows/image.yml publishes it as ghcr.io/hyperstructure/sandbox. +# +# docker build -t sandbox:dev . +# fctl host image build # on an fctl host, with its driver +FROM debian:bookworm-slim + +# The fctl release to install and where releases come from; `fctl host +# image build` passes its own version and download_base. Empty: the latest, +# from https://fctl.sh/releases. CLAUDE_VERSION pins Claude Code (empty: the +# latest release). +ARG FCTL_VERSION= +ARG FCTL_DOWNLOAD= +ARG CLAUDE_VERSION= +ENV LANG=C.UTF-8 LC_ALL=C.UTF-8 TZ=UTC +COPY provision.sh /tmp/provision.sh +RUN bash /tmp/provision.sh && rm /tmp/provision.sh + +USER agent +WORKDIR /home/agent +# As /etc/profile.d/fctl.sh (provision.sh) sets it for login shells. +ENV PATH=/home/agent/.local/bin:/home/agent/.cargo/bin:/home/agent/.bun/bin:/opt/fctl/bin:$PATH +CMD ["tail", "-f", "/dev/null"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..482cf02 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The fctl authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a50da5d --- /dev/null +++ b/README.md @@ -0,0 +1,148 @@ +# Sandbox + +The image [fctl](https://fctl.sh) runs its agents in: Debian with Claude +Code, git, the GitHub CLI, Python, Rust, Node.js, TypeScript and Bun, for +arm64 and amd64. + +```sh +docker pull ghcr.io/hyperstructure/sandbox +docker run --rm -it ghcr.io/hyperstructure/sandbox bash +``` + +fctl hosts pull it themselves: each session runs in a container made from +the tag of the host's own version (hosts of fctl 0.3.0 and earlier build +their own copy instead). It also works as a base for images with more tools +([Building on it](#building-on-it)). + +## What's inside + +- Debian 12 (bookworm), with `LANG=C.UTF-8` and `TZ=UTC`. +- Claude Code, the native build, in `~/.local/bin`: the newest release when + the image was built. +- fctl, the release the tag names, in `/usr/local/bin`. Agents name and + describe their session with `fctl edit`. +- Python 3.15.0 as `python3` and `python` for every user (uv's build of + CPython, in `/opt/uv/python`), and uv 0.13.1. +- Rust 1.99.0 through rustup, with rustfmt, Clippy and the + `wasm32-unknown-unknown` target. +- Node.js 22 with npm and corepack, TypeScript 7.0.2 (`tsc`) and Bun 1.4.3. +- build-essential (gcc, make), cmake, pkg-config and OpenSSL's headers. +- git, the GitHub CLI, ripgrep, fd, jq, ShellCheck, sqlite3, curl, wget, + rsync, lsof, fuser, tree, file, unzip, zip, xz and zstd. + +The versions are set at the top of [`provision.sh`](provision.sh), which +installs everything. + +## Tags + +| Tag | fctl inside | +|---|---| +| `0.4.0` | 0.4.0. Hosts of that version start their sessions from it. | +| `0.4` | The newest 0.4 release. | +| `latest` | The newest release. | + +Each image is built from this repository's `main` with that release of +fctl and the newest Claude Code. A new fctl release gets its image within +the hour, and the newest release's image is rebuilt as things change: + +- **A change merged to `main`**: right away. +- **A new Claude Code release**: in the daily run, at 05:41 UTC. fctl turns + off Claude Code's auto-updater, so an image's Claude Code is as old as the + image. +- **Debian's updates**: at least once a week. + +Images of earlier releases stay as they were last built. + +A rebuild moves the tag. To keep one build, pin its digest: +`ghcr.io/hyperstructure/sandbox:0.4.0@sha256:…`. Its labels say what's in +it: + +| Label | Value | +|---|---| +| `org.opencontainers.image.version`, `sh.fctl.version` | fctl's version | +| `sh.fctl.claude-code.version` | Claude Code's version | +| `org.opencontainers.image.revision` | the commit of this repository it was built from | +| `org.opencontainers.image.created` | when it was built | + +```sh +docker image inspect ghcr.io/hyperstructure/sandbox \ + --format '{{ index .Config.Labels "sh.fctl.claude-code.version" }}' +``` + +## Layout + +fctl and images built `FROM` this one rely on its layout, which stays the +same from release to release. [`check.sh`](check.sh) checks every build for +it. + +- **The user** is `agent`, uid 1000, with the home directory `/home/agent` + and sudo without a password: the sandbox is the boundary. Sessions run as + `agent`, in `~/workspace`. +- **The PATH** starts with `~/.local/bin` (Claude Code and uv's tools), + `~/.cargo/bin` and `~/.bun/bin`, then `/opt/fctl/bin`, where fctl copies + its supervisor, fctl-agent, when a session starts. The image sets it, and + `/etc/profile.d/fctl.sh` sets it for login shells. fctl-agent passes + Claude the PATH, HOME and the locale but none of the image's other + environment variables, so every tool works from the PATH alone. +- **Rust and Bun** belong to `agent`, in `~/.rustup`, `~/.cargo` and + `~/.bun`, so agents add toolchains, targets and global tools without sudo + (`cargo install`, `bun add -g`, and for Python `uv tool install`). Run + `cargo`, `rustup` and `bun` as `agent`. +- **Python** is marked externally managed: packages go into a virtual + environment (`uv venv`, `python3 -m venv`) or come with `uv run --with`. + Debian's Python 3.11, which Node.js depends on, stays in `/usr/bin`, after + `/usr/local/bin` on the PATH. +- **npm** leaves out its funding and audit notices and its update check + (`/usr/etc/npmrc`); `npm audit` still runs when asked. + +## Building on it + +An image that installs something as root switches back to `agent` at the +end: + +```dockerfile +FROM ghcr.io/hyperstructure/sandbox:0.4 +USER root +RUN apt-get update \ + && apt-get install -y --no-install-recommends postgresql-client \ + && rm -rf /var/lib/apt/lists/* +USER agent +``` + +To run it in fctl, name it in a project's Settings → Sandbox → Image, or as +`image` in a host's `host.toml` +([Sandbox images](https://fctl.sh/docs/sandbox-images/) in fctl's docs). +Apple silicon hosts need arm64 images. + +## Building it + +```sh +docker build -t sandbox . +docker run --rm -i sandbox sh -s < check.sh +``` + +`--build-arg FCTL_VERSION=0.4.0` picks the fctl release (default: the +latest) and `--build-arg CLAUDE_VERSION=…` Claude Code's (default: the +latest). On an fctl host, `fctl host image build` builds this repository's +`main` with the host's driver (Apple `container` or Docker) under the +host's image name, or prepares the Lima VM that sessions are cloned from +with `provision.sh`. + +The Image workflow ([`.github/workflows/image.yml`](.github/workflows/image.yml)) +publishes it: each architecture builds on a runner of its own and is +checked with `check.sh` before the two are tagged as one image. Pull +requests that change the image build and check both without publishing. + +## Questions and changes + +Open an issue for a tool the image lacks or something that doesn't work in +it, and report vulnerabilities privately: [SECURITY.md](SECURITY.md). +[AGENTS.md](AGENTS.md) describes how changes are made, for people and for +agents. + +## License + +The files in this repository are under the [MIT license](LICENSE). The image +contains software under its own licenses, among them Claude Code, which +[Anthropic's terms](https://code.claude.com/docs/en/legal-and-compliance) +govern. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..82ea91c --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,10 @@ +# Security policy + +Please report vulnerabilities privately through GitHub's **Security → Report a +vulnerability** on this repository, not in a public issue. We acknowledge +reports within three working days. + +In fctl, the sandbox is the security boundary: each session runs in a VM or +container of its own, and inside it the `agent` user has sudo without a +password. What fctl puts into a sandbox, and how it handles credentials and +billing, is in [fctl's security docs](https://fctl.sh/docs/security/). diff --git a/check.sh b/check.sh new file mode 100755 index 0000000..5ddcce0 --- /dev/null +++ b/check.sh @@ -0,0 +1,56 @@ +#!/bin/sh +# Checks an image built from this repository for what fctl and images built +# FROM it rely on (README.md's Layout): the agent user, sudo, the PATH and the +# tools. Runs inside the image as its user; the image workflow runs it on +# every build: +# +# docker run --rm -i -e FCTL_VERSION=0.3.0 -e CLAUDE_VERSION=2.1.302 IMAGE sh -s < check.sh +# +# FCTL_VERSION and CLAUDE_VERSION, when set, are the versions it must have. +set -eu + +fail() { + echo "check.sh: $*" >&2 + exit 1 +} + +[ "$(id -un)" = agent ] || fail "runs as $(id -un), not agent" +[ "$(id -u)" = 1000 ] || fail "agent has uid $(id -u), not 1000" +[ "$HOME" = /home/agent ] || fail "HOME is $HOME, not /home/agent" +sudo -n true 2>/dev/null || fail "agent cannot use sudo without a password" +# The image's PATH, and login shells', which setup scripts run in and which get +# it from /etc/profile.d/fctl.sh. +login_path=$(bash -lc 'printf %s "$PATH"') +for dir in "$HOME/.local/bin" "$HOME/.cargo/bin" "$HOME/.bun/bin" /opt/fctl/bin; do + for path in "$PATH" "$login_path"; do + case ":$path:" in + *":$dir:"*) ;; + *) fail "$dir is not on the PATH ($path)" ;; + esac + done +done +[ -d /opt/fctl/bin ] || fail "/opt/fctl/bin is missing" +[ -d "$HOME/workspace" ] || fail "$HOME/workspace is missing" +[ -w "$HOME/workspace" ] || fail "$HOME/workspace is not writable" +for tool in sh tail git gh claude fctl python3 python uv rustup cargo rustc node npm corepack tsc bun \ + make cc cmake pkg-config rg fd jq shellcheck sqlite3 curl wget rsync zip tree; do + command -v "$tool" >/dev/null 2>&1 || fail "$tool is not on the PATH" +done +[ "$(command -v claude)" = "$HOME/.local/bin/claude" ] || fail "claude is $(command -v claude), not ~/.local/bin/claude" +# rustup's proxies are there even without a toolchain; these run from HOME +# and the PATH alone, as Claude's shell has them. +for tool in cargo rustc rustfmt cargo-clippy python3 tsc bun; do + "$tool" --version >/dev/null 2>&1 || fail "$tool --version fails" +done +pkg-config --exists openssl || fail "OpenSSL's headers are missing" + +# `2.1.302 (Claude Code)` and `fctl 0.3.0`. +claude=$(claude --version) +claude=${claude%% *} +fctl=$(fctl --version) +fctl=${fctl#fctl } +[ -z "${CLAUDE_VERSION:-}" ] || [ "$claude" = "$CLAUDE_VERSION" ] || + fail "Claude Code is $claude, not $CLAUDE_VERSION" +[ -z "${FCTL_VERSION:-}" ] || [ "$fctl" = "$FCTL_VERSION" ] || + fail "fctl is $fctl, not $FCTL_VERSION" +echo "The image is as expected, with fctl $fctl and Claude Code $claude." diff --git a/provision.sh b/provision.sh new file mode 100755 index 0000000..4e94307 --- /dev/null +++ b/provision.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 # single-quoted scripts expand in the agent's shell +# Provisions an fctl agent sandbox: the tools agents need, Claude Code and +# fctl. Used by the Dockerfile (container sandboxes) and by `fctl host image +# build --driver lima`, which runs it in a Debian 12 VM (fctl-base) as root +# with AGENT_USER set to the VM's user. Runs as root. +# +# FCTL_VERSION picks the fctl release and FCTL_DOWNLOAD where releases come +# from (`fctl host image build` passes its own version and download_base); +# empty means the latest, from https://fctl.sh/releases. CLAUDE_VERSION +# picks Claude Code's (empty: the latest); the image workflow passes the one +# it labels the image with, so that every architecture gets the same. +set -euo pipefail + +AGENT_USER="${AGENT_USER:-agent}" +# Toolchain versions; README.md lists them too. Rust is the release fctl +# builds with (rust-toolchain.toml in hyperstructure/fctl), so sessions +# working on fctl have nothing to download: bump the two together. +NODE_MAJOR="${NODE_MAJOR:-22}" +TYPESCRIPT_VERSION="${TYPESCRIPT_VERSION:-7.0.2}" +BUN_VERSION="${BUN_VERSION:-1.4.3}" +UV_VERSION="${UV_VERSION:-0.13.1}" +PYTHON_VERSION="${PYTHON_VERSION:-3.15.0}" +RUST_VERSION="${RUST_VERSION:-1.99.0}" +FCTL_VERSION="${FCTL_VERSION:-}" +FCTL_DOWNLOAD="${FCTL_DOWNLOAD:-}" +CLAUDE_VERSION="${CLAUDE_VERSION:-}" +export DEBIAN_FRONTEND=noninteractive + +apt-get update +apt-get install -y --no-install-recommends \ + ca-certificates curl wget git gnupg less openssh-client procps psmisc lsof sudo \ + rsync unzip xz-utils zip zstd \ + build-essential cmake pkg-config libssl-dev \ + jq ripgrep fd-find tree file shellcheck sqlite3 +# Debian calls fd `fdfind`. +ln -sf /usr/bin/fdfind /usr/local/bin/fd + +# GitHub CLI +curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /usr/share/keyrings/githubcli-archive-keyring.gpg +echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list + +# Node.js and TypeScript. npm leaves out its funding and audit notices and +# its update check, which only lengthen agents' output. +curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - +apt-get install -y --no-install-recommends gh nodejs +corepack enable || true +npm config set --location=global fund=false audit=false update-notifier=false +npm install -g "typescript@$TYPESCRIPT_VERSION" +npm cache clean --force + +# uv, and Python for everyone: uv's build of CPython in /opt/uv/python, as +# python3 and python in /usr/local/bin. +curl -LsSf "https://astral.sh/uv/$UV_VERSION/install.sh" | env UV_INSTALL_DIR=/usr/local/bin UV_NO_MODIFY_PATH=1 sh +env UV_PYTHON_INSTALL_DIR=/opt/uv/python UV_PYTHON_BIN_DIR=/usr/local/bin UV_NO_CACHE=1 \ + uv python install --default --preview-features python-install-default "$PYTHON_VERSION" + +rm -rf /var/lib/apt/lists/* + +# The agent user. The sandbox is the security boundary, so sudo needs no password. +if ! id "$AGENT_USER" >/dev/null 2>&1; then + useradd -m -s /bin/bash -u 1000 "$AGENT_USER" +fi +echo "$AGENT_USER ALL=(ALL) NOPASSWD:ALL" > "/etc/sudoers.d/90-fctl-$AGENT_USER" +chmod 0440 "/etc/sudoers.d/90-fctl-$AGENT_USER" +mkdir -p /opt/fctl/bin +chown -R "$AGENT_USER" /opt/fctl + +# fctl, for everyone on the machine: agents name and describe their session +# with `fctl edit`. A version that is not published (a development build of +# the host) gets the latest release instead. The host copies its own +# fctl-agent in at session start, so the one in the archive goes. +install_fctl() { + curl -fsSL https://fctl.sh/install | + env FCTL_VERSION="$1" FCTL_DOWNLOAD="$FCTL_DOWNLOAD" FCTL_INSTALL_DIR=/usr/local/bin \ + FCTL_NO_COMPLETIONS=1 sh +} +if ! install_fctl "$FCTL_VERSION"; then + [ -n "$FCTL_VERSION" ] || exit 1 + echo "fctl $FCTL_VERSION is not published; installing the latest release" >&2 + install_fctl "" +fi +rm -f /usr/local/bin/fctl-agent-*-linux + +# Claude Code, installed for the agent user (native build, ~/.local/bin/claude). +AGENT_HOME="$(getent passwd "$AGENT_USER" | cut -d: -f6)" +sudo -u "$AGENT_USER" -H env CLAUDE_VERSION="$CLAUDE_VERSION" \ + bash -c 'curl -fsSL https://claude.ai/install.sh | bash -s -- ${CLAUDE_VERSION:+"$CLAUDE_VERSION"}' +sudo -u "$AGENT_USER" -H mkdir -p "$AGENT_HOME/workspace" + +# The agent's PATH: Claude Code and uv's tools in ~/.local/bin, Rust's and +# Bun's tools, then fctl's supervisor. The Dockerfile sets the same; login +# shells (setup scripts, the Lima driver's) get it from here. +cat > /etc/profile.d/fctl.sh <<'EOF' +PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.bun/bin:/opt/fctl/bin:$PATH" +EOF + +# Runs a script as the agent user in a login shell; it gets the arguments +# after the script as $1…. +as_agent() { + local script="$1" + shift + sudo -u "$AGENT_USER" -H bash -lc "set -euo pipefail; $script" bash "$@" +} + +# Rust, with the components and targets that rust-toolchain.toml lists, and +# Bun, in the agent's home: they find their files through HOME alone +# (fctl-agent passes Claude no RUSTUP_HOME or the like), and agents add +# toolchains, targets and global tools without sudo. +as_agent 'curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | + sh -s -- -y --no-modify-path --profile minimal --default-toolchain "$1" \ + --component rustfmt,clippy --target wasm32-unknown-unknown' "$RUST_VERSION" +as_agent 'curl -fsSL https://bun.sh/install | bash -s "bun-v$1"' "$BUN_VERSION" +as_agent 'ln -sf bun "$HOME/.bun/bin/bunx"' + +# What sessions find on the PATH. +as_agent 'claude --version; fctl --version; python3 --version; uv --version + rustc --version; cargo --version; node --version; tsc --version; bun --version'