Skip to content

feat(install): provision the aforge harness binary alongside af (curl, desktop, docker) - #924

Merged
AbirAbbas merged 8 commits into
mainfrom
feat/aforge-installer
Aug 17, 2026
Merged

feat(install): provision the aforge harness binary alongside af (curl, desktop, docker)#924
AbirAbbas merged 8 commits into
mainfrom
feat/aforge-installer

Conversation

@AbirAbbas

@AbirAbbas AbirAbbas commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

What

aforge is 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 to go build it from a repo they cannot clone. This PR closes that gap on every surface that installs AgentField.

One provisioner in Go, called from everywhere:

Surface How it gets aforge
curl … | bash (scripts/install.sh) new install_aforge()af aforge ensure
iwr … | iex (scripts/install.ps1) new Install-Aforgeagentfield.exe aforge ensure
af skill install --all aforge.EnsureBestEffort once per InstallAll
AgentField Desktop aforge-companion.ts shells out to af aforge ensure on launch
Dockerfile.control-plane-cloud, .python-agent, .go-agent build-time fetch stage → /usr/local/bin/aforge
by hand af aforge ensure [--force]

af harness doctor --provider aforge now reports it, and af harness doctor lists 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 bumping aforge.Version upgrades every surface at once.

Gzipped assets, hash of the uncompressed binary. checksums.txt is 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 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 the doctor would cheerfully report as installed. Dockerfile.control-plane is 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 asked version first, 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 is version_probe_failed and unusable.

Validation contract

Observable behaviours, each covered by a test or an end-to-end run:

  • af aforge ensure on a clean machine leaves an executable $AGENTFIELD_HOME/bin/aforge whose sha256 matches the published checksum, plus a .aforge.version marker written after the binary.
  • A checksum mismatch installs nothing and leaves no marker.
  • A second ensure with a matching marker issues no HTTP request; a differing marker re-downloads; --force re-downloads regardless.
  • AGENTFIELD_SKIP_AFORGE=1 is a no-op everywhere (CLI, skill install, desktop, and the shell installers via --no-aforge / AFORGE_MODE=none).
  • AGENTFIELD_AFORGE_BASE_URL overrides the whole base URL; Options.BaseURL beats the env var, which beats the default.
  • An unsupported OS/arch is a silent no-op, not an error.
  • Provisioning never fails the operation that requested it — skill install, both shell installers and the desktop launch all continue after a failed download.
  • InstallAll fetches the binary exactly once, and --dry-run fetches nothing.
  • The harness doctor reports an aforge that prints a version as usable=true with that exact version and no issues; an aforge that only prints a usage banner on a non-zero exit as usable=false, issues=["version_probe_failed"].
  • The doctor finds a binary that is in $AGENTFIELD_HOME/bin but not yet on PATH.
  • Docker images that run agent nodes have a working aforge on PATH.

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 onto main. GOFLAGS=-buildvcs=false go build ./... clean (the build step this repo's Control Plane CI runs).
  • gofmt -l clean on every touched file. golangci-lint run reports zero findings in the files this PR touches; the 97 it does report are pre-existing elsewhere in control-plane (and that CI step is continue-on-error).
  • Real provisioning: af aforge ensure against the v0.1.0 layout → bin/aforge sha256 e3b8f7bd…13a32dc, byte-identical to the release checksums.txt entry for aforge-linux-amd64; marker file reads v0.1.0. The installed binary answers both aforge version and aforge --version with aforge v0.1.0, exit 0. A second ensure is a true no-op — same inode, mtime and size.
  • Doctor, positive: af harness doctor --provider aforge --json with nothing on PATHinstalled: true, version: "aforge v0.1.0", usable: true, issues: [], resolved out of $AGENTFIELD_HOME/bin. Exit 0, human output aforge: ready (aforge v0.1.0).
  • Doctor, negative (the behaviour change): the same command against a stub aforge that prints a usage banner and exits 1 → usable: false, issues: ["version_probe_failed"] and a non-zero exit. Under the old VersionOptional allowance this same stub reported usable: true.
  • Docker: the aforge-dist stage of Dockerfile.control-plane-cloud built with --no-cache against the v0.1.0 layout — aforge-linux-amd64: OK from sha256sum -c, and the soft-fail warning branch was not taken, so the asset genuinely resolved. Running the stage's binary prints aforge v0.1.0. .python-agent and .go-agent carry a byte-identical fetch stage and the same AFORGE_VERSION=v0.1.0 arg.

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.
  • desktop: npm ci, npm run typecheck, npm test → 435 tests pass (10 of them new).
  • bash -n scripts/install.sh clean. scripts/install.ps1 parses with zero errors under the PowerShell parser.
  • Mutation check on the doctor fix: removing the exit != 0 → not a version guard makes the probe test fail, i.e. the test catches the real bug rather than mirroring the code.
  • Real execution: the provisioned binary ran aforge exec --json to completion — exit 0, one JSON line ("stop":"done", "artifacts":["hello.txt"]), and the file it was asked to write was actually there.
  • Installer: install_aforge from the committed script provisions successfully against a real af; AFORGE_MODE=none and --no-aforge skip it; a non-executable af warns and returns 0. A full install.sh run against release v0.1.129 — whose af predates this command — printed the warning and still exited 0 with "installed successfully", which is the contract.
  • Coverage: the patch-coverage gate first came in at 79.00% against an 80% floor, all of it error branches in the new provisioner. The follow-up test commit takes internal/aforge from 78.0% to 95.5% of statements and the gate's control-plane patch coverage to 94.00% ✅.
  • Docker, full images: all three built and smoke-tested; the in-image sha256 matched the release checksum. The alpine/musl go-agent image runs the same binary (it is a static ELF). The 404 path was exercised: build succeeds, prints the warning, and ships no aforge rather than a broken one.

Notes for review

Follow-ups

  • Flip the Docker fetch from soft-fail to hard-fail once the asset host is live, so a missing harness can never ship silently. Deliberately not in this PR.
  • Dockerfile.demo-python-agent / Dockerfile.demo-go-agent were left alone; add aforge there if the demos grow harness calls.

🤖 Generated with Claude Code

@AbirAbbas
AbirAbbas requested a review from a team as a code owner August 17, 2026 15:22
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Performance

SDK Memory Δ Latency Δ Tests Status
Python 9.0 KB - 0.31 µs -11%

✓ No regressions detected

@github-actions

Copy link
Copy Markdown
Contributor

📊 Coverage gate

Thresholds from .coverage-gate.toml: per-surface ≥ 84%, aggregate ≥ 85%, max per-surface regression ≤ 1.0 pp, max aggregate regression ≤ 0.50 pp.

Surface Current Baseline Δ
control-plane 87.20% 87.40% ↓ -0.20 pp 🟡
sdk-go 92.80% 92.00% ↑ +0.80 pp 🟢
sdk-python 94.20% 93.73% ↑ +0.47 pp 🟢
sdk-typescript 91.17% 90.42% ↑ +0.75 pp 🟢
web-ui 84.76% 84.79% ↓ -0.03 pp 🟡
aggregate 85.66% 85.75% ↓ -0.09 pp 🟡

✅ Gate passed

No surface regressed past the allowed threshold and the aggregate stayed above the floor.

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

📐 Patch coverage gate

Threshold: 80% on lines this PR touches vs origin/main (from .coverage-gate.toml:thresholds.min_patch).

Surface Touched lines Patch coverage Status
control-plane 265 94.00%
sdk-go 0 ➖ no changes
sdk-python 0 ➖ no changes
sdk-typescript 0 ➖ no changes
web-ui 0 ➖ no changes

✅ Patch gate passed

Every surface whose lines were touched by this PR has patch coverage at or above the threshold.

@AbirAbbas

Copy link
Copy Markdown
Contributor Author

recheck

@AbirAbbas AbirAbbas closed this Aug 17, 2026
@AbirAbbas AbirAbbas reopened this Aug 17, 2026
AbirAbbas and others added 8 commits August 17, 2026 13:10
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
AbirAbbas force-pushed the feat/aforge-installer branch from 7d5774b to 8c6f784 Compare August 17, 2026 17:10
@AbirAbbas
AbirAbbas merged commit 480342e into main Aug 17, 2026
42 of 45 checks passed
@AbirAbbas
AbirAbbas deleted the feat/aforge-installer branch August 17, 2026 17:57
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant