From df18a90203f7156ecf4259a5ee1613528990345e Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 2 Sep 2026 09:20:34 -0400 Subject: [PATCH 1/8] fix(install): resolve the latest release without breaking the pipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `curl … install.sh | bash` died at "Resolving latest release" with `curl: (23) Failed writing body`. The script piped the GitHub API response into `grep -m1`, which exits as soon as it has its match; curl then fails its remaining write and, under `set -o pipefail`, the whole `$(...)` is treated as failed even though the tag parsed correctly. On WSL2/bash this reproduced 5/5 from a file, from stdin, and via curl|bash. Resolve the tag from the github.com//releases/latest 302 redirect instead, which also sidesteps the 60 req/hour unauthenticated API limit that CI runners and office NATs hit, and keep the API as a fallback with the body buffered before parsing. While here, make checksum verification a plain function whose failure the caller turns into a hard error, since a `die` inside the old `( cd … )` subshell only exited the subshell. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Abir Abbas --- scripts/install.sh | 72 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 58 insertions(+), 14 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index e296fb3..46c482e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -39,14 +39,48 @@ case "$arch" in esac # --- resolve version ------------------------------------------------------- +# Two rules here, both learned the hard way: +# +# 1. Never pipe curl straight into an early-exiting reader (grep -m1, head). +# Under `set -o pipefail` the reader closes the pipe as soon as it has its +# match, curl fails its remaining write (exit 23, "Failed writing body"), +# and the whole resolution "fails" even though the tag parsed fine. Buffer +# the response into a variable first, then parse. +# +# 2. Prefer the github.com redirect over the REST API. The unauthenticated +# API allows 60 requests/hour per IP, which shared CI runners and office +# NATs exhaust routinely; /releases/latest -> 302 -> /releases/tag/ +# has no such limit. The API is only a fallback. +latest_tag_from_redirect() { + local final tag + final="$(curl -fsSIL -o /dev/null -w '%{url_effective}' "https://github.com/$REPO/releases/latest" 2>/dev/null)" || return 1 + tag="${final##*/releases/tag/}" + # No redirect to a tag (no releases yet, or an unexpected page) — signal + # the caller to fall back rather than returning junk. + [ -n "$tag" ] && [ "$tag" != "$final" ] || return 1 + printf '%s\n' "$tag" +} + +latest_tag_from_api() { + local body tag + body="$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest")" || return 1 + # sed reads the whole buffer (no early exit), so no broken-pipe hazard. + tag="$(printf '%s\n' "$body" | sed -nE 's/.*"tag_name": *"([^"]+)".*/\1/p')" + tag="${tag%%$'\n'*}" + [ -n "$tag" ] || return 1 + printf '%s\n' "$tag" +} + version="${AF_STACK_VERSION:-}" if [ -z "$version" ]; then info "Resolving latest release of $REPO..." - version="$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" \ - | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/')" \ - || die "could not query the GitHub API for the latest release" - [ -n "$version" ] || die "could not determine the latest release tag (is $REPO public and released?)" + version="$(latest_tag_from_redirect)" || version="$(latest_tag_from_api)" \ + || die "could not determine the latest release of $REPO (is it public and released? pin one with AF_STACK_VERSION=vX.Y.Z)" fi +case "$version" in + v[0-9]*|[0-9]*) ;; + *) die "unexpected release tag '$version' (expected something like v0.12.4)" ;; +esac # checksums/archives use the version WITHOUT the leading 'v'. ver_noprefix="${version#v}" @@ -60,18 +94,28 @@ info "Downloading $archive ($version)..." curl -fsSL "$base/$archive" -o "$tmp/$archive" \ || die "download failed. If $REPO is private, this 404s until it's made public. Otherwise check that $archive exists on the release." +verify_checksum() { + # Runs in $tmp. Returns non-zero on any mismatch or missing entry; the + # caller turns that into a hard error (a `die` inside a subshell would + # only exit the subshell). + local expected actual + expected="$(grep -E "[[:space:]]\*?${archive}\$" checksums.txt | awk '{print $1}')" + expected="${expected%%$'\n'*}" + [ -n "$expected" ] || { warn "no entry for $archive in checksums.txt"; return 1; } + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$archive" | awk '{print $1}')" + elif command -v shasum >/dev/null 2>&1; then + actual="$(shasum -a 256 "$archive" | awk '{print $1}')" + else + warn "no sha256sum/shasum available — skipping checksum verification" + return 0 + fi + [ "$actual" = "$expected" ] +} + if curl -fsSL "$base/checksums.txt" -o "$tmp/checksums.txt" 2>/dev/null; then info "Verifying checksum..." - ( cd "$tmp" - if command -v sha256sum >/dev/null 2>&1; then - grep " ${archive}\$" checksums.txt | sha256sum -c - >/dev/null \ - || die "checksum verification failed for $archive" - elif command -v shasum >/dev/null 2>&1; then - grep " ${archive}\$" checksums.txt | shasum -a 256 -c - >/dev/null \ - || die "checksum verification failed for $archive" - else - warn "no sha256sum/shasum available — skipping checksum verification" - fi ) + (cd "$tmp" && verify_checksum) || die "checksum verification failed for $archive" else warn "checksums.txt not found on the release — skipping verification" fi From 644b99a3ed98771868ce5d269da35b1844b35d5a Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 2 Sep 2026 09:20:34 -0400 Subject: [PATCH 2/8] ci: run the install script against the latest release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first line of the README quickstart had no gate: nothing in CI ever executed scripts/install.sh, so it could ship broken. Add an `install-script` job, path-filtered to the script and this workflow, that lints it (bash -n + shellcheck) and runs it in the three shapes users hit — piped from stdin like the README one-liner, from a file, and pinned with AF_STACK_VERSION — asserting `af-stack version` prints a semver each time, and that a tampered checksums.txt is rejected. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Abir Abbas --- .github/workflows/ci.yml | 49 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a6873c..d39433a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,7 @@ jobs: compose: ${{ steps.filter.outputs.compose }} deploy: ${{ steps.filter.outputs.deploy }} images: ${{ steps.filter.outputs.images }} + install_script: ${{ steps.filter.outputs.install_script }} steps: - uses: actions/checkout@v7 - uses: dorny/paths-filter@v4 @@ -88,6 +89,9 @@ jobs: - 'package.json' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' + install_script: + - 'scripts/install.sh' + - '.github/workflows/ci.yml' lint-go: name: Lint (Go) @@ -257,6 +261,51 @@ jobs: - name: Validate Helm, Fly, Railway, Render, and prod compose run: scripts/validate-deploy-targets.py + install-script: + # The first line of the README quickstart is `curl … install.sh | bash`. + # Lint it and actually run it against the latest GitHub release, in the + # same shapes users hit: piped from stdin, from a file, and pinned. + name: Install script + needs: changes + if: needs.changes.outputs.install_script == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - name: Lint + run: | + bash -n scripts/install.sh + shellcheck scripts/install.sh + - name: Install latest release (piped, like the README one-liner) + run: | + AF_STACK_INSTALL_DIR="$RUNNER_TEMP/piped" bash -c 'cat scripts/install.sh | bash' + "$RUNNER_TEMP/piped/af-stack" version | grep -E '^af-stack [0-9]+\.[0-9]+\.[0-9]+' + - name: Install latest release (from file) + run: | + AF_STACK_INSTALL_DIR="$RUNNER_TEMP/file" bash scripts/install.sh + "$RUNNER_TEMP/file/af-stack" version | grep -E '^af-stack [0-9]+\.[0-9]+\.[0-9]+' + - name: Install a pinned version + run: | + AF_STACK_VERSION=v0.12.4 AF_STACK_INSTALL_DIR="$RUNNER_TEMP/pinned" bash scripts/install.sh + "$RUNNER_TEMP/pinned/af-stack" version | grep -F 'af-stack 0.12.4' + - name: Reject a tampered checksum + run: | + set -euo pipefail + # Point the script at a local "release" whose checksums.txt is wrong + # and make sure it refuses to install. + mkdir -p "$RUNNER_TEMP/tamper" + cd "$RUNNER_TEMP/tamper" + curl -fsSL https://github.com/Agent-Field/backai/releases/download/v0.12.4/af-stack_0.12.4_linux_amd64.tar.gz -o af-stack_0.12.4_linux_amd64.tar.gz + printf '%s af-stack_0.12.4_linux_amd64.tar.gz\n' "$(printf 'x%.0s' $(seq 64))" > checksums.txt + # shellcheck disable=SC2016 + if AF_STACK_INSTALL_DIR="$RUNNER_TEMP/tamper/bin" bash -c ' + source <(sed -n "/^verify_checksum()/,/^}/p" "$GITHUB_WORKSPACE/scripts/install.sh") + warn() { echo "warn: $1"; } + archive=af-stack_0.12.4_linux_amd64.tar.gz + verify_checksum'; then + echo "verify_checksum accepted a bad checksum" >&2; exit 1 + fi + build-app-images: name: Build app images needs: changes From 4df88edd5b4eea71fd51f9d24de5fd8825630f78 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 2 Sep 2026 09:40:54 -0400 Subject: [PATCH 3/8] fix(env): ship the KMS dev sentinel in .env.example, not a fake key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh clone could not boot. `.env.example` set `AF_STACK_KMS_KEY=change-me-to-a-real-key`, and since the KMS boot gate (#141) a key that is set but cannot be loaded is fatal by design — so `af-stack dev`, `af-stack mode`, and `cp .env.example .env && docker compose up` all seeded that placeholder and the runtime crash-looped with "refusing to start: KMS is configured but the key could not be loaded". Maintainers never saw it because their local .env files already carried real keys, and the release-smoke compose hardcodes a hex key. Use the runtime's well-known dev sentinel `dev-secret-change-me` instead, which is also the docker-compose.yml default, so the example boots with the deterministic dev KEK and a warning exactly like a missing key does. Fix the deploy skill doc that named the auth-secret default as the KMS sentinel; setting that value would trip the same fatal. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Abir Abbas --- .env.example | 9 +++++++-- skills/af-stack/rules/deploy.md | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index a963dd7..dff4910 100644 --- a/.env.example +++ b/.env.example @@ -59,8 +59,13 @@ AF_STACK_AUTH_SECRET=change-me-to-a-real-secret # or azure and provide a base64 encrypted data key instead. AF_STACK_KMS_PROVIDER=env -# KMS key for env-provider secrets encryption (generate with: openssl rand -hex 32) -AF_STACK_KMS_KEY=change-me-to-a-real-key +# KMS key for env-provider secrets encryption. The value below is the +# runtime's well-known DEV SENTINEL (the same default docker-compose.yml +# uses): it boots with a deterministic dev key and logs a warning. Any +# other value that is not 32 hex-encoded bytes makes the runtime refuse to +# start, because a misconfigured key must never silently disable the vault. +# For anything beyond local dev, generate a real one: openssl rand -hex 32 +AF_STACK_KMS_KEY=dev-secret-change-me # Cloud BYOK providers unwrap this encrypted data key at runtime boot. # The plaintext must be 32 bytes after cloud KMS decrypt/unwrap. diff --git a/skills/af-stack/rules/deploy.md b/skills/af-stack/rules/deploy.md index 98f96c8..46bd7bb 100644 --- a/skills/af-stack/rules/deploy.md +++ b/skills/af-stack/rules/deploy.md @@ -173,8 +173,8 @@ create tenant → issue API key → set budget → open customer app. - **Dev `docker.sock` mounting in production** — use gVisor or Firecracker for sandboxes. -- **`AF_STACK_KMS_KEY=dev-secret-change-me-in-prod`** — replace with a - real 32-byte hex value. +- **`AF_STACK_KMS_KEY=dev-secret-change-me`** (the dev sentinel) — replace + with a real 32-byte hex value. - **Default better-auth signing key** — `AF_STACK_AUTH_SECRET` must be a real random value. From 7fbe839da4ac055f3c065ff5c89f9c2d599c9a01 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 2 Sep 2026 09:40:54 -0400 Subject: [PATCH 4/8] test(secrets): pin that the committed .env.example boots the runtime TestEnvExampleBootsKMS reads the repo's .env.example the way compose and the CLI's .env seeding do, loads the KMS cipher from it, and runs the same preflight the runtime does at boot. It fails against the previous placeholder with the exact reason a fresh clone crash-looped, and passes with the dev sentinel. Add .env.example to CI's Go path filter so the test runs whenever the example changes, not only when Go code does. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Abir Abbas --- .github/workflows/ci.yml | 3 + .../internal/secrets/env_example_test.go | 76 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 services/runtime/internal/secrets/env_example_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d39433a..ee1832e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,9 @@ jobs: - 'go.mod' - 'go.sum' - '.golangci.yml' + # secrets.TestEnvExampleBootsKMS pins the quickstart contract + # that the committed example env boots the runtime. + - '.env.example' python: - '**/*.py' - 'pyproject.toml' diff --git a/services/runtime/internal/secrets/env_example_test.go b/services/runtime/internal/secrets/env_example_test.go new file mode 100644 index 0000000..0186086 --- /dev/null +++ b/services/runtime/internal/secrets/env_example_test.go @@ -0,0 +1,76 @@ +package secrets + +import ( + "bufio" + "context" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestEnvExampleBootsKMS pins the quickstart contract: the committed +// .env.example, copied verbatim to .env (which `af-stack dev`, `af-stack +// mode`, the README, and AGENTS.md all do), must yield a KMS cipher the +// runtime can boot with. +// +// The runtime refuses to start when AF_STACK_KMS_KEY is set to something it +// cannot load (kmsBootDecision in cmd/af-stack), so an example value that is +// neither the dev sentinel nor 32 hex-encoded bytes turns every fresh clone +// into a crash loop. That is exactly what shipped while the example said +// "change-me-to-a-real-key". +func TestEnvExampleBootsKMS(t *testing.T) { + values := readEnvExample(t) + for _, k := range []string{ + "AF_STACK_KMS_PROVIDER", + "AF_STACK_KMS_KEY", + "AF_STACK_KMS_ENCRYPTED_DATA_KEY", + "AF_STACK_KMS_ENCRYPTED_DATA_KEY_FILE", + } { + t.Setenv(k, values[k]) + } + + quiet := slog.New(slog.NewTextHandler(io.Discard, nil)) + c, err := LoadCipher(context.Background(), quiet) + if err != nil { + t.Fatalf(".env.example AF_STACK_KMS_KEY=%q does not boot the runtime: %v\n"+ + "use the dev sentinel %q or 32 random bytes hex-encoded", + values["AF_STACK_KMS_KEY"], err, devKEKSentinel) + } + if err := c.Preflight(); err != nil { + t.Fatalf("cipher built from .env.example fails preflight: %v", err) + } +} + +// readEnvExample parses the repo's .env.example into KEY=VALUE pairs, +// skipping comments and blank lines — the same view docker compose and the +// CLI's .env seeding take of the file. +func readEnvExample(t *testing.T) map[string]string { + t.Helper() + path := filepath.Join("..", "..", "..", "..", ".env.example") + f, err := os.Open(path) + if err != nil { + t.Fatalf("open %s: %v", path, err) + } + defer f.Close() + + values := map[string]string{} + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + values[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), `"'`) + } + if err := sc.Err(); err != nil { + t.Fatalf("read %s: %v", path, err) + } + return values +} From a5b08560cefc8e0ab63b57162c3b468e312b320f Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 2 Sep 2026 09:42:44 -0400 Subject: [PATCH 5/8] =?UTF-8?q?docs(quickstart):=20stop=20telling=20users?= =?UTF-8?q?=20to=20put=20$(openssl=20=E2=80=A6)=20in=20.env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs-site quickstart had readers write `AF_STACK_KMS_KEY=$(openssl rand -hex 32)` into .env. A .env file is not a shell: docker compose hands the runtime the literal string, which is not hex, and the runtime refuses to start. Show the dev sentinel as the value and say to paste the command's output for a real key. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Abir Abbas --- docs-site/src/content/docs/get-started/quickstart.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/get-started/quickstart.md b/docs-site/src/content/docs/get-started/quickstart.md index c7fc508..7d9f915 100644 --- a/docs-site/src/content/docs/get-started/quickstart.md +++ b/docs-site/src/content/docs/get-started/quickstart.md @@ -21,8 +21,11 @@ Open `.env` and set one provider key: ```bash OPENROUTER_API_KEY=sk-or-v1-... -# Optional: a 64-char hex KMS key. dev-secret-change-me works for local. -AF_STACK_KMS_KEY=$(openssl rand -hex 32) +# Optional. The default `dev-secret-change-me` boots with a dev key. For a +# real key, paste the OUTPUT of `openssl rand -hex 32` — a .env file does +# not run shell commands, and anything that is not 64 hex characters makes +# the runtime refuse to start. +AF_STACK_KMS_KEY=dev-secret-change-me ``` ## 2. Boot the stack From f8415570f85b1ebe2b283da5d07b8442531c7ea1 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 2 Sep 2026 10:23:02 -0400 Subject: [PATCH 6/8] fix(install): fail closed on checksums, land on PATH, prove the binary runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardening from an adversarial review of the installer, each item reproduced before it was fixed: - Checksum fetch failed open: any transport error on checksums.txt was reported as "not found on the release" and the install continued unverified. Every release publishes that file, so branch on the HTTP status, refuse to install otherwise, and print the status and curl's error. AF_STACK_SKIP_CHECKSUM=1 is the explicit override. - Install dir off PATH: for any non-root user /usr/local/bin is not writable, the binary landed in ~/.local/bin, and the script printed a version banner and exited 0 — followed by `af-stack dev: command not found`. Prefer a usable candidate that is already on PATH, and when the chosen dir is not, print the exact export line and the rc-file line for the user's shell instead of a bare banner. Also flag an older copy shadowing the new one. - The post-install probe was silenced and ran after the success banner; it now gates the banner, so a noexec mount or truncated archive fails loudly. `install -m 0755` replaces `mv` so a sudo install is root-owned. mkdir gets the same sudo fallback and actionable error as the copy, and dir resolution moves before the download so a bad target fails in under a second. - AF_STACK_VERSION=0.12.4 (bare, the spelling release.yml and the prod compose use) 404'd and blamed repo visibility; try the other prefix once, keeping the checksums fetch on the tag that resolved. - AF_STACK_DOWNLOAD_BASE points both fetches at a mirror, which is what lets CI drive the real script against a local fake release. - Trailing slashes on AF_STACK_INSTALL_DIR no longer defeat the PATH check or print a doubled slash. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Abir Abbas --- scripts/install.sh | 153 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 124 insertions(+), 29 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 46c482e..6ee82b5 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -7,9 +7,12 @@ # curl -fsSL https://raw.githubusercontent.com/Agent-Field/backai/main/scripts/install.sh | bash # # Env overrides: -# AF_STACK_VERSION pin a version, e.g. v0.6.0 (default: latest release) -# AF_STACK_INSTALL_DIR install target (default: /usr/local/bin, else ~/.local/bin) -# AF_STACK_REPO owner/name (default: Agent-Field/backai) +# AF_STACK_VERSION pin a release, e.g. v0.12.4 or 0.12.4 (default: latest release) +# AF_STACK_INSTALL_DIR install target (default: /usr/local/bin if writable, else ~/.local/bin) +# AF_STACK_REPO owner/name (default: Agent-Field/backai) +# AF_STACK_DOWNLOAD_BASE fetch the archive and checksums.txt from this URL instead of +# GitHub Releases (air-gapped mirrors, tests) +# AF_STACK_SKIP_CHECKSUM set to 1 to install even when checksums.txt cannot be fetched set -euo pipefail REPO="${AF_STACK_REPO:-Agent-Field/backai}" @@ -38,6 +41,44 @@ case "$arch" in *) die "unsupported architecture '$arch'" ;; esac +# --- pick the install dir -------------------------------------------------- +# Done before any download so a bad target fails in under a second, not +# after fetching and verifying the archive. +on_path() { case ":$PATH:" in *":$1:"*) return 0 ;; esac; return 1; } + +# usable_dir: the directory is writable, or does not exist yet and its +# nearest existing ancestor is writable (so mkdir -p will succeed). +usable_dir() { + local d="$1" + while [ ! -d "$d" ]; do + case "$d" in + */*) d="${d%/*}"; [ -n "$d" ] || d="/" ;; + *) d="." ;; + esac + done + [ -w "$d" ] +} + +dir="${AF_STACK_INSTALL_DIR:-}" +# Trim trailing slashes (tab completion adds them) so the PATH comparison +# below matches and the printed path is clean. +while [ "$dir" != "/" ] && [ "${dir%/}" != "$dir" ]; do dir="${dir%/}"; done +if [ -z "$dir" ]; then + # Prefer a usable candidate that is already on PATH, so `af-stack` works + # in this shell right away; otherwise fall back to the first usable one. + for candidate in /usr/local/bin "$HOME/.local/bin"; do + if on_path "$candidate" && usable_dir "$candidate"; then dir="$candidate"; break; fi + done + if [ -z "$dir" ]; then + if usable_dir /usr/local/bin; then dir=/usr/local/bin; else dir="$HOME/.local/bin"; fi + fi +fi +if [ ! -d "$dir" ]; then + mkdir -p "$dir" 2>/dev/null \ + || { command -v sudo >/dev/null 2>&1 && sudo mkdir -p "$dir"; } \ + || die "could not create $dir (set AF_STACK_INSTALL_DIR to a writable dir)" +fi + # --- resolve version ------------------------------------------------------- # Two rules here, both learned the hard way: # @@ -81,19 +122,47 @@ case "$version" in v[0-9]*|[0-9]*) ;; *) die "unexpected release tag '$version' (expected something like v0.12.4)" ;; esac -# checksums/archives use the version WITHOUT the leading 'v'. -ver_noprefix="${version#v}" -archive="${BINARY}_${ver_noprefix}_${os}_${arch}.tar.gz" -base="https://github.com/$REPO/releases/download/$version" +# --- download -------------------------------------------------------------- +# Archives and checksums use the version WITHOUT the leading 'v'. +archive_for() { printf '%s' "${BINARY}_${1#v}_${os}_${arch}.tar.gz"; } +download_base() { + if [ -n "${AF_STACK_DOWNLOAD_BASE:-}" ]; then + printf '%s' "${AF_STACK_DOWNLOAD_BASE%/}" + else + printf '%s' "https://github.com/$REPO/releases/download/$1" + fi +} -# --- download + verify ----------------------------------------------------- tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT -info "Downloading $archive ($version)..." -curl -fsSL "$base/$archive" -o "$tmp/$archive" \ - || die "download failed. If $REPO is private, this 404s until it's made public. Otherwise check that $archive exists on the release." +# fetch_archive TAG: sets archive/base for TAG and downloads the archive. +fetch_archive() { + archive="$(archive_for "$1")" + base="$(download_base "$1")" + # Errors are kept for the final message so a failed first spelling of the + # tag does not print a stray 404 when the retry succeeds. + curl -fsSL "$base/$archive" -o "$tmp/$archive" 2>"$tmp/curl.err" +} + +info "Downloading $(archive_for "$version") ($version)..." +if ! fetch_archive "$version"; then + # Tags are normally v-prefixed, but AF_STACK_VERSION is often given bare + # (release.yml and docker-compose.prod.yml use it that way). Try the other + # spelling once before giving up. + case "$version" in + v*) alt="${version#v}" ;; + *) alt="v$version" ;; + esac + if fetch_archive "$alt"; then + version="$alt" + else + die "download failed: $(download_base "$version")/$(archive_for "$version") (also tried tag $alt; $(tr -d '\n' < "$tmp/curl.err")). Check that the release exists and has a ${os}/${arch} asset; a private $REPO 404s until it is made public." + fi +fi + +# --- verify ---------------------------------------------------------------- verify_checksum() { # Runs in $tmp. Returns non-zero on any mismatch or missing entry; the # caller turns that into a hard error (a `die` inside a subshell would @@ -113,31 +182,57 @@ verify_checksum() { [ "$actual" = "$expected" ] } -if curl -fsSL "$base/checksums.txt" -o "$tmp/checksums.txt" 2>/dev/null; then +if [ "${AF_STACK_SKIP_CHECKSUM:-0}" = "1" ]; then + warn "AF_STACK_SKIP_CHECKSUM=1 — installing $archive without checksum verification" +else + # Every release publishes checksums.txt, so failing to fetch it is a + # transport problem, not a missing file. Fail closed rather than quietly + # installing an unverified binary; branch on the HTTP status because + # `curl -f` exits 22 for every 4xx/5xx alike. + code="$(curl -sSL -o "$tmp/checksums.txt" -w '%{http_code}' "$base/checksums.txt" 2>"$tmp/curl.err")" || code=000 + if [ "$code" != "200" ]; then + detail="" + if [ -s "$tmp/curl.err" ]; then detail=" ($(tr -d '\n' < "$tmp/curl.err"))"; fi + die "could not fetch $base/checksums.txt (HTTP $code$detail) — refusing to install an unverified binary. Retry, or set AF_STACK_SKIP_CHECKSUM=1 to override." + fi info "Verifying checksum..." (cd "$tmp" && verify_checksum) || die "checksum verification failed for $archive" -else - warn "checksums.txt not found on the release — skipping verification" fi tar -xzf "$tmp/$archive" -C "$tmp" [ -f "$tmp/$BINARY" ] || die "archive did not contain the '$BINARY' binary" -chmod +x "$tmp/$BINARY" # --- install --------------------------------------------------------------- -dir="${AF_STACK_INSTALL_DIR:-}" -if [ -z "$dir" ]; then - if [ -w /usr/local/bin ] 2>/dev/null; then dir="/usr/local/bin"; else dir="$HOME/.local/bin"; fi -fi -mkdir -p "$dir" - -if mv "$tmp/$BINARY" "$dir/$BINARY" 2>/dev/null; then :; -elif command -v sudo >/dev/null 2>&1 && sudo mv "$tmp/$BINARY" "$dir/$BINARY"; then :; +# `install` (not mv) so a sudo install lands root-owned instead of leaving a +# user-writable executable in a system PATH directory. +if install -m 0755 "$tmp/$BINARY" "$dir/$BINARY" 2>/dev/null; then : +elif command -v sudo >/dev/null 2>&1 && sudo install -m 0755 "$tmp/$BINARY" "$dir/$BINARY"; then : else die "could not install to $dir (set AF_STACK_INSTALL_DIR to a writable dir)"; fi -info "Installed $BINARY $version to $dir/$BINARY" -case ":$PATH:" in - *":$dir:"*) ;; - *) warn "$dir is not on your PATH — add it, e.g.: export PATH=\"$dir:\$PATH\"" ;; -esac -"$dir/$BINARY" version 2>/dev/null || true +# Prove the installed binary runs before claiming success; a noexec mount or +# a truncated download would otherwise surface as the user's next command. +if ! probe="$("$dir/$BINARY" version 2>&1)"; then + die "installed $dir/$BINARY but it will not run: ${probe:-no output} (a 'Permission denied' here usually means $dir is on a noexec mount)" +fi +info "Installed $probe to $dir/$BINARY" + +# --- is it reachable as `af-stack`? ---------------------------------------- +resolved="$(command -v "$BINARY" 2>/dev/null || true)" +if on_path "$dir" && [ "$resolved" = "$dir/$BINARY" ]; then + : # `af-stack dev` works in this shell right now +elif on_path "$dir"; then + warn "$dir is on your PATH but '$BINARY' currently resolves to ${resolved:-nothing} — remove or update that older copy" +else + case "${SHELL##*/}" in + zsh) rc_hint="echo 'export PATH=\"$dir:\$PATH\"' >> ~/.zshrc" ;; + bash) rc_hint="echo 'export PATH=\"$dir:\$PATH\"' >> ~/.bashrc" ;; + fish) rc_hint="fish_add_path $dir" ;; + *) rc_hint="" ;; + esac + warn "$dir is not on your PATH, so '$BINARY dev' will not be found yet. In this shell run:" + # shellcheck disable=SC2016 # the literal $PATH is what the user should type + printf ' export PATH="%s:%s"\n' "$dir" '$PATH' >&2 + if [ -n "$rc_hint" ]; then + printf ' and to make it permanent:\n %s\n' "$rc_hint" >&2 + fi +fi From 46424a4a0aededcbd55b6c1693936867f4cf1d99 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 2 Sep 2026 10:23:02 -0400 Subject: [PATCH 7/8] ci(install-script): drive the real script and make the gate able to block The first cut of this job could not catch the regressions it exists for: it was not in ci-success's needs (so a red run never blocked a merge), its version assertions only checked the semver shape (a resolver returning the wrong tag passed), the pinned step pinned the latest release (a no-op pin passed), and the tamper check sourced a sed-extracted copy of verify_checksum instead of running install.sh (a fail-open at the call site passed). Now: ci-success requires the job (path-filtered skips still count as success, as for every other job); the two newest release tags come from gh, independently of the redirect the script uses, and every assertion is an exact `af-stack ` match; the pin uses the previous release, both v-prefixed and bare, so ignoring it can never pass; and a local http.server serves the real archive as a fake release so the script itself is run against a tampered checksums.txt (must exit non-zero with the checksum message and install nothing), a missing one (must refuse with HTTP 404 unless AF_STACK_SKIP_CHECKSUM=1), and the correct one (must install). Co-Authored-By: Claude Fable 5.1 Signed-off-by: Abir Abbas --- .github/workflows/ci.yml | 88 ++++++++++++++++++++++++++++----------- docs/branch-protection.md | 4 +- 2 files changed, 66 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee1832e..34e02c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -266,48 +266,87 @@ jobs: install-script: # The first line of the README quickstart is `curl … install.sh | bash`. - # Lint it and actually run it against the latest GitHub release, in the - # same shapes users hit: piped from stdin, from a file, and pinned. + # Lint it and actually run it — against the real latest release in the + # shapes users hit (piped from stdin, from a file, pinned with and + # without the v prefix), and against a local fake release to prove the + # checksum gate fails closed. Version assertions are exact: a resolver + # that silently picks the wrong tag must go red here. name: Install script needs: changes if: needs.changes.outputs.install_script == 'true' runs-on: ubuntu-latest timeout-minutes: 10 + env: + GH_TOKEN: ${{ github.token }} + MIRROR: http://127.0.0.1:8765 steps: - uses: actions/checkout@v7 - name: Lint run: | bash -n scripts/install.sh shellcheck scripts/install.sh - - name: Install latest release (piped, like the README one-liner) + - name: Resolve the two newest releases + # Via gh (authenticated), deliberately NOT via the same redirect the + # script uses, so the assertions below are independent of it. + run: | + latest="$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName -q .tagName)" + prev="$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --exclude-pre-releases --limit 10 --json tagName -q '.[1].tagName')" + [ -n "$latest" ] || { echo "could not resolve the latest release" >&2; exit 1; } + echo "latest=$latest prev=${prev:-}" + { echo "LATEST=$latest"; echo "PREV=$prev"; } >> "$GITHUB_ENV" + - name: Install latest (piped, like the README one-liner) run: | AF_STACK_INSTALL_DIR="$RUNNER_TEMP/piped" bash -c 'cat scripts/install.sh | bash' - "$RUNNER_TEMP/piped/af-stack" version | grep -E '^af-stack [0-9]+\.[0-9]+\.[0-9]+' - - name: Install latest release (from file) + "$RUNNER_TEMP/piped/af-stack" version | grep -Fx "af-stack ${LATEST#v}" + - name: Install latest (from file) run: | AF_STACK_INSTALL_DIR="$RUNNER_TEMP/file" bash scripts/install.sh - "$RUNNER_TEMP/file/af-stack" version | grep -E '^af-stack [0-9]+\.[0-9]+\.[0-9]+' - - name: Install a pinned version + "$RUNNER_TEMP/file/af-stack" version | grep -Fx "af-stack ${LATEST#v}" + - name: Install the previous release, pinned (v-prefixed and bare) + # Pinning the *previous* release means ignoring the pin can never + # pass, and the step never needs a hand-bumped version. + run: | + if [ -z "$PREV" ]; then echo "only one release exists; nothing to pin"; exit 0; fi + AF_STACK_VERSION="$PREV" AF_STACK_INSTALL_DIR="$RUNNER_TEMP/pinned" bash scripts/install.sh + "$RUNNER_TEMP/pinned/af-stack" version | grep -Fx "af-stack ${PREV#v}" + AF_STACK_VERSION="${PREV#v}" AF_STACK_INSTALL_DIR="$RUNNER_TEMP/pinned-bare" bash scripts/install.sh + "$RUNNER_TEMP/pinned-bare/af-stack" version | grep -Fx "af-stack ${PREV#v}" + - name: Serve a local fake release run: | - AF_STACK_VERSION=v0.12.4 AF_STACK_INSTALL_DIR="$RUNNER_TEMP/pinned" bash scripts/install.sh - "$RUNNER_TEMP/pinned/af-stack" version | grep -F 'af-stack 0.12.4' - - name: Reject a tampered checksum + mkdir -p "$RUNNER_TEMP/release" && cd "$RUNNER_TEMP/release" + gh release download "$LATEST" --repo "$GITHUB_REPOSITORY" --pattern '*_linux_amd64.tar.gz' --pattern 'checksums.txt' + cp checksums.txt checksums.good + nohup python3 -m http.server 8765 --bind 127.0.0.1 >/dev/null 2>&1 & + for _ in $(seq 1 40); do curl -fs "$MIRROR/checksums.txt" >/dev/null && break; sleep 0.25; done + curl -fs "$MIRROR/checksums.txt" >/dev/null + - name: Reject a tampered checksums.txt (real script, nothing installed) run: | - set -euo pipefail - # Point the script at a local "release" whose checksums.txt is wrong - # and make sure it refuses to install. - mkdir -p "$RUNNER_TEMP/tamper" - cd "$RUNNER_TEMP/tamper" - curl -fsSL https://github.com/Agent-Field/backai/releases/download/v0.12.4/af-stack_0.12.4_linux_amd64.tar.gz -o af-stack_0.12.4_linux_amd64.tar.gz - printf '%s af-stack_0.12.4_linux_amd64.tar.gz\n' "$(printf 'x%.0s' $(seq 64))" > checksums.txt - # shellcheck disable=SC2016 - if AF_STACK_INSTALL_DIR="$RUNNER_TEMP/tamper/bin" bash -c ' - source <(sed -n "/^verify_checksum()/,/^}/p" "$GITHUB_WORKSPACE/scripts/install.sh") - warn() { echo "warn: $1"; } - archive=af-stack_0.12.4_linux_amd64.tar.gz - verify_checksum'; then - echo "verify_checksum accepted a bad checksum" >&2; exit 1 + cd "$RUNNER_TEMP/release" + sed -E 's/^[0-9a-f]{64}/0000000000000000000000000000000000000000000000000000000000000000/' checksums.good > checksums.txt + if AF_STACK_VERSION="$LATEST" AF_STACK_DOWNLOAD_BASE="$MIRROR" AF_STACK_INSTALL_DIR="$RUNNER_TEMP/tampered" \ + bash "$GITHUB_WORKSPACE/scripts/install.sh" 2>err.txt; then + echo "installer accepted a tampered checksum" >&2; cat err.txt; exit 1 fi + grep -q 'checksum verification failed' err.txt + [ ! -e "$RUNNER_TEMP/tampered/af-stack" ] + - name: Refuse to install when checksums.txt is unreachable, unless overridden + run: | + cd "$RUNNER_TEMP/release" && rm -f checksums.txt + if AF_STACK_VERSION="$LATEST" AF_STACK_DOWNLOAD_BASE="$MIRROR" AF_STACK_INSTALL_DIR="$RUNNER_TEMP/nochecksums" \ + bash "$GITHUB_WORKSPACE/scripts/install.sh" 2>err.txt; then + echo "installer proceeded without checksums.txt" >&2; cat err.txt; exit 1 + fi + grep -q 'HTTP 404' err.txt + [ ! -e "$RUNNER_TEMP/nochecksums/af-stack" ] + AF_STACK_SKIP_CHECKSUM=1 AF_STACK_VERSION="$LATEST" AF_STACK_DOWNLOAD_BASE="$MIRROR" AF_STACK_INSTALL_DIR="$RUNNER_TEMP/skipped" \ + bash "$GITHUB_WORKSPACE/scripts/install.sh" + "$RUNNER_TEMP/skipped/af-stack" version | grep -Fx "af-stack ${LATEST#v}" + - name: Accept the correct checksums.txt from the mirror + run: | + cd "$RUNNER_TEMP/release" && cp checksums.good checksums.txt + AF_STACK_VERSION="$LATEST" AF_STACK_DOWNLOAD_BASE="$MIRROR" AF_STACK_INSTALL_DIR="$RUNNER_TEMP/mirror" \ + bash "$GITHUB_WORKSPACE/scripts/install.sh" + "$RUNNER_TEMP/mirror/af-stack" version | grep -Fx "af-stack ${LATEST#v}" build-app-images: name: Build app images @@ -457,6 +496,7 @@ jobs: test-typescript, validate-compose, validate-deploy-targets, + install-script, helm-kind-smoke, fly-staging-smoke, prod-compose-smoke, diff --git a/docs/branch-protection.md b/docs/branch-protection.md index eecf589..9f8789f 100644 --- a/docs/branch-protection.md +++ b/docs/branch-protection.md @@ -32,8 +32,8 @@ ruleset**, targeting `main`, with the same rules as below. | Required checks | `CI Success`, `Security Success` | `CI Success` (`.github/workflows/ci.yml`) aggregates lint, test, -compose/deploy validation, docs, and the DCO job. Path-filtered jobs -that skip still count as success. +compose/deploy validation, the install-script gate, docs, and the DCO +job. Path-filtered jobs that skip still count as success. `Security Success` (`.github/workflows/security.yml`) aggregates pnpm/npm audit, pip-audit, gosec, and trivy. CodeQL uploads results From 8f7f20e7aaeab2c6e7edc0cf2335368b50cb8b9c Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 2 Sep 2026 10:23:02 -0400 Subject: [PATCH 8/8] docs: describe the installer's knobs and that Node is optional for dev cli-distribution.md now lists bare pins, the redirect-based resolution, fail-closed checksums with AF_STACK_SKIP_CHECKSUM, the AF_STACK_DOWNLOAD_BASE mirror, and the PATH hint. README's quickstart said the only prerequisite is Docker; `af-stack dev` also uses Node for port auto-allocation and silently falls back to the defaults without it, so say so in one line. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Abir Abbas --- README.md | 4 +++- docs/cli-distribution.md | 12 +++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7594a15..667cd20 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,9 @@ BackAI is currently in beta and under active development. Expect rapid improveme ## Quickstart -Prerequisite: Docker with Compose. +Prerequisite: Docker with Compose. Node 18+ is optional: `af-stack dev` +uses it to auto-allocate conflict-free ports and falls back to the +defaults without it. ```bash git clone https://github.com/Agent-Field/backai.git diff --git a/docs/cli-distribution.md b/docs/cli-distribution.md index d19309c..c091a26 100644 --- a/docs/cli-distribution.md +++ b/docs/cli-distribution.md @@ -12,9 +12,15 @@ verifies its checksum, and puts it on your PATH: curl -fsSL https://raw.githubusercontent.com/Agent-Field/backai/main/scripts/install.sh | bash ``` -Pin a version or install dir with env: `AF_STACK_VERSION=v0.6.0`, -`AF_STACK_INSTALL_DIR="$HOME/.local/bin"`. Source: -[`scripts/install.sh`](../scripts/install.sh). +Pin a version or install dir with env: `AF_STACK_VERSION=v0.12.4` (bare +`0.12.4` works too), `AF_STACK_INSTALL_DIR="$HOME/.local/bin"`. The +script resolves the latest tag from the `releases/latest` redirect (no +GitHub API rate limit), verifies the archive against the release's +`checksums.txt` and refuses to install if that file cannot be fetched +(`AF_STACK_SKIP_CHECKSUM=1` overrides), and can pull both files from a +mirror instead of GitHub with `AF_STACK_DOWNLOAD_BASE=https://…`. When the +install dir is not on your PATH it prints the `export PATH=…` line to run. +Source: [`scripts/install.sh`](../scripts/install.sh). **2. `go install`** (any platform with Go ≥ 1.25):