Provision a Go toolchain at install time, the way Python's already is - #875
Merged
Conversation
`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>
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. |
…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>
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.
af installprovisions 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 runsuv python installand downloads a standalone build → pyenv discovery → only then an actionable error.resolveGoToolchainwalked: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-plannerandpr-afare 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=jsonnames 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.gzis 15026 regular files and 1667 directories with zero links, so strictness costs nothing on the genuine download.Only once
go/bin/gois 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=1restores exactly today's behaviour for environments that must not fetch binaries.The version gate was also wrong
resolveGoToolchainrefused an ambientgoolder than the module'sgo.moddirective. But since 1.21 the toolchain downloads and switches to the requested version itself —GOTOOLCHAIN=autois the default. I confirmed it: go1.25.4 on PATH built a module declaringgo 1.26.0by 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 GOTOOLCHAINis now consulted, and only a genuinely incapable toolchain (older than 1.21, or pinnedlocal) falls through to provisioning.usableGoBinaryprobes runnabilityIt now checks that the binary runs, not that it exists. A cached toolchain whose
golost 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:TestResolveGoToolchain_ProvisionsAndReusesCacheTestResolveGoToolchain_AmbientCanAutoSwitchGOTOOLCHAIN=local→ falls throughTestResolveGoToolchain_AmbientPinnedLocalFallsThroughTestProvisionGoToolchain_RejectsChecksumMismatch../traversal → refused, no partial cacheTestProvisionGoToolchain_RejectsTraversalAtomicallyTestExtractGoZip_SuccessAndTraversalRefusalTestResolveGoToolchain_MissingToolchainGates from
control-plane/:go build ./...,go vet ./...,go test ./internal/packages/... -count=1all clean;gofmt -lclean on every changed file. (internal/packages/spinner_test.gois unformatted identically onorigin/main— pre-existing, untouched.)Verified end to end with no
goon PATH at all (env -i PATH=/usr/bin:/bin): installing the pr-af repo followed itssuperseded_byredirect, printedProvisioned Go 1.26.5, built the node, and registered it aspr-afwith a 13MB binary. A second install printedUsing provisioned Go 1.26.5and completed in 2s off the cache.Not covered
Only exercised on Linux. Windows takes the
.zipextraction 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-cloud→golang:1.25-bookworm), so this gap was local and desktop installs only.🤖 Generated with Claude Code