Skip to content

Provision a Go toolchain at install time, the way Python's already is - #875

Merged
AbirAbbas merged 4 commits into
mainfrom
feat/provision-go-toolchain
Aug 4, 2026
Merged

Provision a Go toolchain at install time, the way Python's already is#875
AbirAbbas merged 4 commits into
mainfrom
feat/provision-go-toolchain

Conversation

@AbirAbbas

Copy link
Copy Markdown
Contributor

af install provisions a Python node's prerequisite but not a Go node's. That gap now decides whether the two nodes AgentField ships are installable at all.

The asymmetry

resolveVenvInterpreter (pyinterp.go) walks: ambient interpreter → provisionViaUv, which runs uv python install and downloads a standalone build → pyenv discovery → only then an actionable error.

resolveGoToolchain walked: firstOnPath("go") → error. No provisioning rung at all.

So a user installing a Go node without Go on PATH hit a hard failure, where the equivalent Python user gets an interpreter fetched for them. Go is not preinstalled on macOS, Windows, or most Linux images — and both swe-planner and pr-af are now served by their Go implementations, so from the desktop app that was both of the nodes we ship.

Provisioning

The official index go.dev/dl/?mode=json names the newest stable archive for this GOOS/GOARCH together with its SHA256. That archive is downloaded, hashed as it streams, and verified against the published sum before anything is unpacked — a mismatch is a hard error, never a fallthrough.

Extraction goes into a temp directory inside the toolchains dir and refuses absolute paths, .. traversal, symlinks, hard links, and any entry type it does not understand. I checked that policy against the real artifact rather than trusting fixtures: go1.26.5.linux-amd64.tar.gz is 15026 regular files and 1667 directories with zero links, so strictness costs nothing on the genuine download.

Only once go/bin/go is confirmed runnable is the tree renamed into ~/.agentfield/toolchains/<version>/, so an interrupted download cannot leave a half-tree a later run mistakes for a toolchain. A lost race against a concurrent installer resolves to the winner's copy.

AGENTFIELD_DISABLE_GO_PROVISIONING=1 restores exactly today's behaviour for environments that must not fetch binaries.

The version gate was also wrong

resolveGoToolchain refused an ambient go older than the module's go.mod directive. But since 1.21 the toolchain downloads and switches to the requested version itself — GOTOOLCHAIN=auto is the default. I confirmed it: go1.25.4 on PATH built a module declaring go 1.26.0 by fetching 1.26.0 on its own.

So that gate rejected builds Go would have completed — someone on Go 1.21 with a node needing 1.23 was told to upgrade for nothing. go env GOTOOLCHAIN is now consulted, and only a genuinely incapable toolchain (older than 1.21, or pinned local) falls through to provisioning.

usableGoBinary probes runnability

It now checks that the binary runs, not that it exists. A cached toolchain whose go lost its execute bit — a restore, a umask, a copy across filesystems — would otherwise be handed back from the cache forever and fail inside the build with a raw permission error that provisioning never retries. That is the same failure mode SWE-AF's engine check was hardened against.

It deliberately does not require the version string to parse: this file treats an unparseable version as "unknown, don't gate" everywhere else, and a toolchain we cannot label still builds.

Validation

Behavioural tests, with the HTTP index and archive behind a seam served from httptest — nothing in the default suite touches the network:

Behaviour Test
No ambient Go → provisions, build succeeds TestResolveGoToolchain_ProvisionsAndReusesCache
Second install reuses cache, no re-download same
Ambient ≥1.21 older than go.mod → proceeds, self-switches TestResolveGoToolchain_AmbientCanAutoSwitch
GOTOOLCHAIN=local → falls through TestResolveGoToolchain_AmbientPinnedLocalFallsThrough
SHA256 mismatch → refused, nothing extracted TestProvisionGoToolchain_RejectsChecksumMismatch
../ traversal → refused, no partial cache TestProvisionGoToolchain_RejectsTraversalAtomically
zip path, incl. traversal TestExtractGoZip_SuccessAndTraversalRefusal
Provisioning disabled → today's actionable error TestResolveGoToolchain_MissingToolchain

Gates from control-plane/: go build ./..., go vet ./..., go test ./internal/packages/... -count=1 all clean; gofmt -l clean on every changed file. (internal/packages/spinner_test.go is unformatted identically on origin/main — pre-existing, untouched.)

Verified end to end with no go on PATH at all (env -i PATH=/usr/bin:/bin): installing the pr-af repo followed its superseded_by redirect, printed Provisioned Go 1.26.5, built the node, and registered it as pr-af with a 13MB binary. A second install printed Using provisioned Go 1.26.5 and completed in 2s off the cache.

Not covered

Only exercised on Linux. Windows takes the .zip extraction path, which is genuinely different code and is covered by unit tests but not by a real install; macOS is untested too. Worth a manual pass on both before relying on this in a release.

Relationship to the other PRs

Independent — merges in any order relative to #873, Agent-Field/pr-af#64 and Agent-Field/SWE-AF#122. But it should land before the release that ships them, since without it the consolidated Go nodes are uninstallable for anyone without a Go toolchain. Note the cloud image already ships Go (Dockerfile.control-plane-cloudgolang:1.25-bookworm), so this gap was local and desktop installs only.

🤖 Generated with Claude Code

`af install` provisions a Python node's prerequisite but not a Go node's.
`resolveVenvInterpreter` walks ambient interpreter → `provisionViaUv`, which
runs `uv python install` and *downloads* a standalone build → pyenv → only then
an actionable error. `resolveGoToolchain` walked `firstOnPath("go")` → error.
There was no provisioning rung at all, so installing a Go node without Go on
PATH was a hard failure where the equivalent Python user gets an interpreter
fetched for them.

That asymmetry now decides whether the two nodes AgentField ships are
installable at all: both the SWE fleet and pr-af are served by their Go
implementations, so a user with no Go toolchain — which is most users, since Go
is not preinstalled anywhere — could not install either from the desktop app.

Adds the missing rung. The official index at go.dev/dl?mode=json names the
newest stable archive for this GOOS/GOARCH along with its SHA256; that archive
is downloaded, hashed as it streams, and **checked against the published sum
before anything is unpacked**. Extraction is into a temp directory inside the
toolchains dir and refuses absolute paths, `..` traversal, symlinks, hard links,
and any entry type it does not understand — verified against the real
go1.26.5.linux-amd64 tarball, which is 15026 regular files and 1667 directories
and no links, so the strict policy costs nothing on the genuine artifact. Only
after `go/bin/go` is confirmed runnable is the tree renamed into
`<AGENTFIELD_HOME>/toolchains/<version>/`, so an interrupted download can never
leave a half-tree that a later run mistakes for a toolchain. A lost race to a
concurrent installer resolves to the winner's copy.

`AGENTFIELD_DISABLE_GO_PROVISIONING=1` restores exactly today's behaviour for
environments that must not fetch binaries.

Also stops refusing an ambient Go that would have worked. Since 1.21 the
toolchain downloads and switches to whatever `go.mod` asks for on its own
(`GOTOOLCHAIN=auto`, the default) — confirmed: go1.25.4 on PATH built a module
declaring `go 1.26.0` by fetching 1.26.0 itself. The old version gate rejected
that, so a user on Go 1.21 with a node needing 1.23 was told to upgrade for no
reason. `go env GOTOOLCHAIN` is now consulted, and only a genuinely incapable
toolchain — older than 1.21, or pinned `local` — falls through to provisioning.

`usableGoBinary` probes that the binary *runs* rather than that it exists. A
cached toolchain whose `go` lost its execute bit would otherwise be handed back
from the cache forever and fail inside the build with a raw permission error —
the same failure mode SWE-AF's engine check was hardened against. It
deliberately does not require the version to parse, since this file treats an
unparseable version as "unknown, don't gate" everywhere else.

Verified end to end with no `go` on PATH at all: installing the pr-af repo
followed its `superseded_by` redirect, provisioned Go 1.26.5, built the node,
and registered it as `pr-af`; the second install reused the cache in 2s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AbirAbbas
AbirAbbas requested a review from a team as a code owner August 4, 2026 22:05
Comment thread control-plane/internal/packages/gotoolchain_provision.go Fixed
Comment thread control-plane/internal/packages/gotoolchain_provision.go Fixed
@github-actions

github-actions Bot commented Aug 4, 2026

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.00% 87.40% ↓ -0.40 pp 🟡
sdk-go 92.70% 92.00% ↑ +0.70 pp 🟢
sdk-python 93.82% 93.73% ↑ +0.09 pp 🟢
sdk-typescript 91.05% 90.42% ↑ +0.63 pp 🟢
web-ui 84.75% 84.79% ↓ -0.04 pp 🟡
aggregate 85.58% 85.75% ↓ -0.17 pp 🟡

✅ Gate passed

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

@github-actions

github-actions Bot commented Aug 4, 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 290 83.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 and others added 3 commits August 4, 2026 18:23
…try policy

CodeQL flagged both extraction loops as `go/zipslip` (2 high). The lexical
`safeArchivePath` check was sound — Clean, reject a `..` prefix, then confirm
the joined path is still under the root — but "the scanner does not recognise my
sanitizer" is a weak answer on a security finding, and a lexical check is only
as good as its own reasoning about paths.

Extraction now writes through `os.Root` (Go 1.24+; this module is on 1.25).
Every create and mkdir resolves inside the destination at the syscall level and
refuses to escape it — `..`, absolute paths, and symlinked parents alike. That
is a structural guarantee rather than a string comparison, so it holds even
where a lexical argument would have to be re-checked. The name check stays as a
cheap first gate that produces a legible error, but it is no longer what makes
this safe.

Verified on the genuine artifact, not just fixtures: the real
go1.26.5.linux-amd64 tarball still extracts completely through os.Root — all
15026 files — and the provisioned toolchain runs.

The entry policy had no tests at all, which is how it should not have been
shipped: refusing symlinks and hard links is a security control, and the
happy-path tests never touched it because a well-formed Go archive contains
nothing unusual. Now pinned directly — symlinks (escaping and innocuous), hard
links, character devices and FIFOs are each refused by name and leave nothing
behind; directories and regular files extract with their mode preserved, which
matters because a `go/bin/go` without its execute bit is not a toolchain.

Also covers the degradation paths that decide whether a user gets guidance or a
plumbing error: an unresolvable AgentField home, a `toolchains` path that is not
a directory, an unreachable archive host, a non-200 or malformed index, and an
index with no build for this platform — each declines quietly so the caller
keeps its actionable "install Go" message.

Patch coverage 65% → 80.6%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r is told

Raises patch coverage 78% → 82%, but the point is which paths: every one of
these changes what a user sees when something goes wrong.

- A download that fails its checksum, or dies partway through, must not degrade
  into "no `go` toolchain was found on PATH". That message tells someone their
  machine is missing Go when the truth is that what we fetched could not be
  trusted — so integrity failures stay loud and a truncated transfer leaves
  nothing cached for the next run to trip over.
- An unwritable cache directory declines quietly instead, because there the
  install-Go guidance is exactly the right advice.

Also makes the fake toolchain in the fixtures report `go1.99.0` rather than
`go9.9.9`. `installedGoVersion` only recognises `go1`/`go2` prefixes, so the old
value silently exercised the unparseable-version branch on every provisioning
test and never the normal one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The provisioning notice printed only on success, so the ~64MB transfer happened
in silence behind an install spinner that cannot tick during it. On a slow link
that is a minute of nothing, shown to precisely the people this feature exists
for: users with no Go, who have no reason to expect installing an agent to fetch
a compiler, and who reasonably read a still spinner as a hang.

Now announced up front with the version and size, from the `size` the download
index already publishes alongside the checksum.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AbirAbbas
AbirAbbas merged commit 91fd73f into main Aug 4, 2026
26 checks passed
@AbirAbbas
AbirAbbas deleted the feat/provision-go-toolchain branch August 4, 2026 23:41
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.

2 participants