feat(install): provision the aforge harness binary alongside af (curl, desktop, docker) - #924
Merged
Conversation
Contributor
Performance
✓ No regressions detected |
Contributor
📊 Coverage gateThresholds from
✅ Gate passedNo surface regressed past the allowed threshold and the aggregate stayed above the floor. |
Contributor
📐 Patch coverage gateThreshold: 80% on lines this PR touches vs
✅ Patch gate passedEvery surface whose lines were touched by this PR has patch coverage at or above the threshold. |
Contributor
Author
|
recheck |
AgentField's harness providers spawn `aforge exec --json`, but nothing in the
product ever put that binary on a machine — every install surface assumed the
user had built it from a private repo. This adds the provisioning half.
Shaped on internal/furrow's provisioner (same lock, marker, atomic-rename and
best-effort contract) with three deliberate differences:
* assets are distributed gzipped, so the stream is decompressed before it is
hashed — checksums.txt carries the sha256 of the UNCOMPRESSED binary — and
the decompressed size is capped so a bad endpoint cannot gzip-bomb us;
* all six platforms are mapped, not the three furrow happens to ship;
* Options.Force exists so `af aforge ensure --force` can bypass the marker.
AGENTFIELD_AFORGE_BASE_URL overrides the whole base (mirrors, staging hosts);
AGENTFIELD_SKIP_AFORGE=1 makes the whole thing a no-op.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`af aforge ensure` mirrors `af furrow ensure` — Ensure, not EnsureBestEffort,
because someone who asks for the binary by name is owed the failure — plus a
--force that bypasses the version marker.
The doctor needed two new ideas to describe aforge honestly:
* VersionArgs, because providers do not agree on how to be asked. aforge
will answer `version`; every other provider answers `--version`.
* VersionOptional, because the pinned aforge build answers BOTH with its
whole usage banner on exit 1. A present, executable binary is enough to
call it usable; it reports version "unknown" with an informational
"version_unavailable" issue until the next aforge release adds the
subcommand. The probe requires exit 0 before it believes any output —
without that it would file the usage banner as the installed version.
The probe also falls back to $AGENTFIELD_HOME/bin when PATH misses: the shell
that just ran `af aforge ensure` has not re-read PATH, and reporting a binary
we installed thirty seconds ago as missing is the wrong answer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… installers Three entry points, one implementation. `af skill install --all`, the curl installer and the PowerShell installer all end up calling the same Go provisioner, so there is exactly one place that knows the pinned version, the download host and the checksum rules. The skillkit hook goes in InstallAll rather than install(): InstallAll is the one call every fresh machine makes, and hooking each skill would re-download a 35MB binary once per catalog entry. --dry-run stays side-effect free. Both shell installers keep aforge strictly optional — a failed provision warns and moves on, because by that point the control plane is already installed and working. Opt out with --no-aforge / AFORGE_MODE=none (sh) or -NoAforge / $env:AFORGE_MODE='none' (ps1; a piped `iwr | iex` cannot pass a switch, hence the env var). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A desktop-only install never runs the curl installer, so it would have had af and no harness. The app now shells out to `af aforge ensure` right after it resolves the CLI — no bundled payload, so there is one download path and the upgrade rules stay in Go. Deliberately not extraResources: bundling the binary would fork the install path and freeze the pinned version at package time, and the app would still need the runtime check for machines that already had an older copy. Shaped like tray-companion.ts: planAforge() is pure so the skip rules are unit tested, and the effect takes injected deps so no test ever spawns anything. Fire-and-forget with a once-per-launch latch set before the await, so a dead network delays nothing and two callers still produce one download. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The cloud control plane (single-container topology), the python-agent image and the go-agent image all host agent nodes, and a node that reaches for a harness in a container without one fails at spawn. Dockerfile.control-plane is left alone: it is distroless, has no shell, and by construction runs agents elsewhere. Soft fetch, hard verify. The asset host goes live with the website deploy, so a 404 has to degrade to "this image ships without aforge" rather than break every image build in the meantime — but a download that does land is always checked against the published sha256, taken after gunzip because checksums.txt hashes the uncompressed binary. A mismatch fails the build. The COPY takes the fetch stage's output DIRECTORY, not a fixed file path: when the fetch was skipped the directory is empty and the COPY is a no-op, instead of planting a zero-byte `aforge` on PATH that `af harness doctor` would cheerfully report as installed. aforge is a statically linked ELF, so the debian-built asset runs unchanged on the musl/alpine go-agent base. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Python SDK told users to `go build -o aforge ./cmd/aforge` from a private repo when a harness call failed — advice nobody could follow. It now names the command that actually exists on their machine. Also documents the two knobs the provisioner reads (AGENTFIELD_AFORGE_BASE_URL for mirrors, AGENTFIELD_SKIP_AFORGE for air-gapped hosts) and the shell installers' equivalents. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI's patch-coverage gate came in at 79.00% against an 80% floor — all of the misses were the error branches of the new code, which is exactly the code you want covered: a provisioner is mostly failure handling. Adds tests for the paths that were only reachable by breaking something: an unwritable bin directory (MkdirAll and flock failures), a `bin/aforge` path occupied by a directory so the atomic rename fails, a marker path likewise, a corrupt/truncated/non-gzip body, an unreachable host, a checksums file with malformed hex or no line for the asset, and every branch of home resolution. Also exercises the default GOOS/GOARCH path and asserts — via a fake RoundTripper rather than a real request — that the default base URL is the one actually dialled. internal/aforge: 78.0% → 95.5% of statements. No production code changed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…version from the doctor aforge-v2 cut its first semver release, so every surface that provisioned build-9b3ff482de3f now provisions v0.1.0: the pinned constant in control-plane/internal/aforge, the AFORGE_VERSION build arg in the cloud control-plane / python-agent / go-agent images, and the base-URL example in .env.example. The release also ships a real `version` subcommand (and `--version`), which retires the reason aforge was allowed to be "usable" without one. Drop the aforge-only VersionOptional allowance from the harness doctor: a binary that cannot name itself is now version_probe_failed and unusable, exactly like every other provider. VersionArgs still tries "version" before "--version", and a non-zero exit is still rejected so a usage banner never lands in the version field. The Docker fetch stays soft-fail on a 404 (hard-verify on a hit) — that is a separate documented follow-up, not this change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AbirAbbas
force-pushed
the
feat/aforge-installer
branch
from
August 17, 2026 17:10
7d5774b to
8c6f784
Compare
2 tasks
AbirAbbas
added a commit
that referenced
this pull request
Aug 17, 2026
…forge download (#926) The installer only skipped its own explicit `af aforge ensure` step, but `af skill install --all` (which runs first) triggers the same best-effort aforge provisioning hook (skillkit/install.go), so a user who opted out still got the 35 MB binary downloaded into ~/.agentfield/bin. Regressed with #924, which added both the hook and the flag. Export AGENTFIELD_SKIP_AFORGE=1 for the whole run when AFORGE_MODE=none — that env gate is the single switch the Go side honours — so every `af` (and af-tray) invocation the script makes respects the opt-out. Verified against v0.1.130-rc.5 into an isolated HOME: patched installer with --no-aforge installs af + skills + furrow and no aforge; the unpatched script installs aforge despite the flag. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
aforgeis the coding harness AgentField's own harness providers spawn (aforge exec --json -w <dir>) — but until now nothing in the product ever put that binary on a machine. The Python provider shipped, the Go/TS providers are on the way, and the install hint told users togo buildit from a repo they cannot clone. This PR closes that gap on every surface that installs AgentField.One provisioner in Go, called from everywhere:
curl … | bash(scripts/install.sh)install_aforge()→af aforge ensureiwr … | iex(scripts/install.ps1)Install-Aforge→agentfield.exe aforge ensureaf skill install --allaforge.EnsureBestEffortonce perInstallAllaforge-companion.tsshells out toaf aforge ensureon launchDockerfile.control-plane-cloud,.python-agent,.go-agent/usr/local/bin/aforgeaf aforge ensure [--force]af harness doctor --provider aforgenow reports it, andaf harness doctorlists it alongside the other providers.Pinned version:
aforge v0.1.0— aforge-v2's first semver release.Why these shapes
Runtime ensure, not a bundled binary. The desktop app deliberately does not add aforge to
extraResources. Bundling would fork the install path and freeze the pinned version at package time, and the app would still need a runtime check for machines that already carry an older copy. One code path, and bumpingaforge.Versionupgrades every surface at once.Gzipped assets, hash of the uncompressed binary.
checksums.txtis published verbatim from the aforge release and carries the sha256 of the uncompressed binaries, so the download is decompressed before it is hashed. The decompressed size is capped (96 MiB) so a wrong or hostile endpoint cannot gzip-bomb the process.Soft fetch, hard verify in Docker. The asset host goes live with a separate website deploy, so a 404 has to degrade to "this image ships without aforge" rather than break every image build in the meantime — but a download that does land is always checksum-verified, and a mismatch fails the build. The
COPYtakes the fetch stage's output directory, not a fixed file path: when the fetch was skipped the directory is empty and the COPY is a no-op, instead of planting a zero-byteaforgeon PATH that the doctor would cheerfully report as installed.Dockerfile.control-planeis left alone — it is distroless, has no shell, and by construction runs agents elsewhere.The doctor needed one new idea, and one guard. Providers do not agree on how to be asked for a version, hence per-spec
VersionArgs— aforge is askedversionfirst, then--version. And the probe requires exit 0 before it believes any output, because a CLI that does not recognise a version argument answers with its whole usage banner; without that guard the doctor would file the banner as the installed version. aforge is otherwise held to exactly the same bar as every other provider: a binary that cannot name itself isversion_probe_failedand unusable.Validation contract
Observable behaviours, each covered by a test or an end-to-end run:
af aforge ensureon a clean machine leaves an executable$AGENTFIELD_HOME/bin/aforgewhose sha256 matches the published checksum, plus a.aforge.versionmarker written after the binary.ensurewith a matching marker issues no HTTP request; a differing marker re-downloads;--forcere-downloads regardless.AGENTFIELD_SKIP_AFORGE=1is a no-op everywhere (CLI, skill install, desktop, and the shell installers via--no-aforge/AFORGE_MODE=none).AGENTFIELD_AFORGE_BASE_URLoverrides the whole base URL;Options.BaseURLbeats the env var, which beats the default.InstallAllfetches the binary exactly once, and--dry-runfetches nothing.usable=truewith that exact version and no issues; an aforge that only prints a usage banner on a non-zero exit asusable=false, issues=["version_probe_failed"].$AGENTFIELD_HOME/binbut not yet onPATH.aforgeonPATH.How verified
Go / Python / desktop suites plus real end-to-end runs against a mirror of the published layout (URL withheld — it is a local host).
Re-verified for the v0.1.0 pin:
go test ./internal/aforge/... ./internal/cli/... ./internal/skillkit/... -count=1— all pass, before and after rebasing ontomain.GOFLAGS=-buildvcs=false go build ./...clean (the build step this repo's Control Plane CI runs).gofmt -lclean on every touched file.golangci-lint runreports zero findings in the files this PR touches; the 97 it does report are pre-existing elsewhere incontrol-plane(and that CI step iscontinue-on-error).af aforge ensureagainst the v0.1.0 layout →bin/aforgesha256e3b8f7bd…13a32dc, byte-identical to the releasechecksums.txtentry foraforge-linux-amd64; marker file readsv0.1.0. The installed binary answers bothaforge versionandaforge --versionwithaforge v0.1.0, exit 0. A secondensureis a true no-op — same inode, mtime and size.af harness doctor --provider aforge --jsonwith nothing onPATH→installed: true, version: "aforge v0.1.0", usable: true, issues: [], resolved out of$AGENTFIELD_HOME/bin. Exit 0, human outputaforge: ready (aforge v0.1.0).aforgethat prints a usage banner and exits 1 →usable: false, issues: ["version_probe_failed"]and a non-zero exit. Under the oldVersionOptionalallowance this same stub reportedusable: true.aforge-diststage ofDockerfile.control-plane-cloudbuilt with--no-cacheagainst the v0.1.0 layout —aforge-linux-amd64: OKfromsha256sum -c, and the soft-fail warning branch was not taken, so the asset genuinely resolved. Running the stage's binary printsaforge v0.1.0..python-agentand.go-agentcarry a byte-identical fetch stage and the sameAFORGE_VERSION=v0.1.0arg.Carried over from the pre-bump verification (unchanged by this bump):
ruff check .(pinned 0.15.22) clean;./scripts/run_pytest.sh -k "availability or harness"→ 246 passed.npm ci,npm run typecheck,npm test→ 435 tests pass (10 of them new).bash -n scripts/install.shclean.scripts/install.ps1parses with zero errors under the PowerShell parser.exit != 0 → not a versionguard makes the probe test fail, i.e. the test catches the real bug rather than mirroring the code.aforge exec --jsonto completion — exit 0, one JSON line ("stop":"done","artifacts":["hello.txt"]), and the file it was asked to write was actually there.install_aforgefrom the committed script provisions successfully against a realaf;AFORGE_MODE=noneand--no-aforgeskip it; a non-executableafwarns and returns 0. A fullinstall.shrun against releasev0.1.129— whoseafpredates this command — printed the warning and still exited 0 with "installed successfully", which is the contract.internal/aforgefrom 78.0% to 95.5% of statements and the gate'scontrol-planepatch coverage to 94.00% ✅.aforgerather than a broken one.Notes for review
aforge.Versionincontrol-plane/internal/aforge/ensure.goisv0.1.0; the three Dockerfiles carry the matchingARG AFORGE_VERSION=v0.1.0. Every other surface reads the Go constant, so a future bump is those four lines.VersionOptionalis gone. It existed only because the previously pinned edge build had noversionsubcommand. v0.1.0 has a realversion(and--version), so the allowance is deleted rather than left dangling, and aforge now fails the doctor like any other provider when it cannot report a version.v0.1.0directory. Until then the Docker fetch takes its documented soft-fail path andaf aforge ensurereports a 404 — which is why every caller treats provisioning as best-effort.control-plane/internal/cli/harness_doctor.go: both PRs add a row toharnessProviderSpecs. Resolve by keeping both rows — this PR'saforgeentry and Add Pi and OMP harnesses with OMP as the default #913's — leavingVersionArgsunset on Add Pi and OMP harnesses with OMP as the default #913's row (it defaults to{"--version"}, i.e. today's behaviour).Follow-ups
Dockerfile.demo-python-agent/Dockerfile.demo-go-agentwere left alone; add aforge there if the demos grow harness calls.🤖 Generated with Claude Code