From 4dda59bb08d54240d4f1518a634e18aad34e0e8e Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 17 Aug 2026 11:02:17 -0400 Subject: [PATCH 1/8] feat(aforge): pinned-binary provisioner for the aforge coding harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- control-plane/internal/aforge/ensure.go | 256 +++++++++++++++++++ control-plane/internal/aforge/ensure_test.go | 250 ++++++++++++++++++ control-plane/internal/aforge/lock_other.go | 7 + control-plane/internal/aforge/lock_unix.go | 20 ++ 4 files changed, 533 insertions(+) create mode 100644 control-plane/internal/aforge/ensure.go create mode 100644 control-plane/internal/aforge/ensure_test.go create mode 100644 control-plane/internal/aforge/lock_other.go create mode 100644 control-plane/internal/aforge/lock_unix.go diff --git a/control-plane/internal/aforge/ensure.go b/control-plane/internal/aforge/ensure.go new file mode 100644 index 000000000..3caff9275 --- /dev/null +++ b/control-plane/internal/aforge/ensure.go @@ -0,0 +1,256 @@ +// Package aforge provisions the pinned aforge coding-harness binary that AgentField's harness providers spawn. +package aforge + +import ( + "bufio" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +const ( + // Version is deliberately pinned. Bump it only when af should distribute a + // newer, reviewed aforge build. + Version = "build-9b3ff482de3f" + defaultBaseURL = "https://agentfield.ai/downloads/aforge/" + Version + baseURLEnv = "AGENTFIELD_AFORGE_BASE_URL" + skipEnv = "AGENTFIELD_SKIP_AFORGE" + versionMarker = ".aforge.version" + // aforge is a ~35MB static Go binary; cap the DECOMPRESSED stream so a + // hostile or wrong endpoint cannot gzip-bomb the process. + maxDecompressedBytes = 96 << 20 +) + +// Options controls how Ensure selects and installs aforge. +type Options struct { + GOOS, GOARCH string + Home string + BaseURL string + Client *http.Client + // Force re-downloads even when the version marker already matches + // (what `af aforge ensure --force` sets). + Force bool +} + +// AssetName returns the release asset name for a platform (no .gz suffix). +func AssetName(goos, goarch string) (string, bool) { + switch goos + "/" + goarch { + case "linux/amd64": + return "aforge-linux-amd64", true + case "linux/arm64": + return "aforge-linux-arm64", true + case "darwin/amd64": + return "aforge-darwin-amd64", true + case "darwin/arm64": + return "aforge-darwin-arm64", true + case "windows/amd64": + return "aforge-windows-amd64.exe", true + case "windows/arm64": + return "aforge-windows-arm64.exe", true + default: + return "", false + } +} + +// BinaryName is the installed file name ("aforge", or "aforge.exe" on windows). +func BinaryName(goos string) string { + if goos == "windows" { + return "aforge.exe" + } + return "aforge" +} + +// Ensure installs aforge if it is supported and not already executable. +func Ensure(opts Options) error { + if os.Getenv(skipEnv) == "1" { + return nil + } + goos, goarch := opts.GOOS, opts.GOARCH + if goos == "" { + goos = runtime.GOOS + } + if goarch == "" { + goarch = runtime.GOARCH + } + asset, supported := AssetName(goos, goarch) + if !supported { + return nil + } + + home, err := agentfieldHome(opts.Home) + if err != nil { + return err + } + destination := filepath.Join(home, "bin", BinaryName(goos)) + markerPath := filepath.Join(home, "bin", versionMarker) + binDir := filepath.Dir(destination) + if err := os.MkdirAll(binDir, 0o755); err != nil { + return fmt.Errorf("create aforge bin directory: %w", err) + } + unlock, err := lockAforge(filepath.Join(binDir, ".aforge.lock")) + if err != nil { + return fmt.Errorf("lock aforge installation: %w", err) + } + defer func() { _ = unlock() }() + if !opts.Force && alreadyInstalled(destination, markerPath) { + return nil + } + + baseURL := strings.TrimRight(opts.BaseURL, "/") + if baseURL == "" { + baseURL = strings.TrimRight(os.Getenv(baseURLEnv), "/") + } + if baseURL == "" { + baseURL = defaultBaseURL + } + client := opts.Client + if client == nil { + client = &http.Client{ + Transport: &http.Transport{ + DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 30 * time.Second, + }, + // A ~35MB asset can take longer than three minutes over a slow link. + Timeout: 10 * time.Minute, + } + } + + checksums, err := download(client, baseURL+"/checksums.txt") + if err != nil { + return fmt.Errorf("download checksums: %w", err) + } + want, err := checksumFor(checksums, asset) + if err != nil { + return err + } + binary, err := downloadGzip(client, baseURL+"/"+asset+".gz") + if err != nil { + return fmt.Errorf("download %s: %w", asset, err) + } + got := sha256.Sum256(binary) + if !strings.EqualFold(hex.EncodeToString(got[:]), want) { + return fmt.Errorf("checksum mismatch for %s", asset) + } + + tmp, err := os.CreateTemp(binDir, ".aforge-*") + if err != nil { + return fmt.Errorf("create temporary aforge file: %w", err) + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + if _, err = tmp.Write(binary); err == nil { + err = tmp.Chmod(0o755) + } + if closeErr := tmp.Close(); err == nil { + err = closeErr + } + if err != nil { + return fmt.Errorf("write temporary aforge file: %w", err) + } + if err := os.Rename(tmpName, destination); err != nil { + return fmt.Errorf("install aforge: %w", err) + } + // Written after the binary is in place: a marker without a usable binary + // would make the next Ensure skip a repair it should have done. + if err := os.WriteFile(markerPath, []byte(Version+"\n"), 0o644); err != nil { + return fmt.Errorf("record aforge version: %w", err) + } + return nil +} + +func alreadyInstalled(destination, markerPath string) bool { + info, err := os.Stat(destination) + // The exec-bit check is meaningless on Windows, where NTFS carries no such + // mode and Go reports 0666 for every regular file. + if err != nil || !info.Mode().IsRegular() || (runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0) { + return false + } + installed, err := os.ReadFile(markerPath) + return err == nil && strings.TrimSpace(string(installed)) == Version +} + +// EnsureBestEffort is the install-path contract: provisioning can emit one +// short warning, but can never fail the operation that requested it. +func EnsureBestEffort(opts Options, warnings io.Writer) error { + if err := Ensure(opts); err != nil && warnings != nil { + _, _ = fmt.Fprintf(warnings, "warning: aforge was not installed: %v\n", err) + } + return nil +} + +func agentfieldHome(override string) (string, error) { + if override != "" { + return override, nil + } + if home := os.Getenv("AGENTFIELD_HOME"); home != "" { + return home, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return filepath.Join(home, ".agentfield"), nil +} + +func download(client *http.Client, url string) ([]byte, error) { + response, err := client.Get(url) + if err != nil { + return nil, err + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s", response.Status) + } + return io.ReadAll(io.LimitReader(response.Body, maxDecompressedBytes)) +} + +func downloadGzip(client *http.Client, url string) ([]byte, error) { + response, err := client.Get(url) + if err != nil { + return nil, err + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s", response.Status) + } + gz, err := gzip.NewReader(response.Body) + if err != nil { + return nil, err + } + defer func() { _ = gz.Close() }() + binary, err := io.ReadAll(io.LimitReader(gz, maxDecompressedBytes+1)) + if err != nil { + return nil, err + } + if len(binary) > maxDecompressedBytes { + return nil, fmt.Errorf("aforge payload exceeds %d bytes", maxDecompressedBytes) + } + return binary, nil +} + +func checksumFor(data []byte, asset string) (string, error) { + scanner := bufio.NewScanner(strings.NewReader(string(data))) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) == 2 && strings.TrimPrefix(fields[1], "*") == asset { + if _, err := hex.DecodeString(fields[0]); err != nil || len(fields[0]) != sha256.Size*2 { + return "", fmt.Errorf("invalid checksum for %s", asset) + } + return fields[0], nil + } + } + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("read checksums: %w", err) + } + return "", fmt.Errorf("checksum missing for %s", asset) +} diff --git a/control-plane/internal/aforge/ensure_test.go b/control-plane/internal/aforge/ensure_test.go new file mode 100644 index 000000000..d962683b7 --- /dev/null +++ b/control-plane/internal/aforge/ensure_test.go @@ -0,0 +1,250 @@ +package aforge + +import ( + "bytes" + "compress/gzip" + "crypto/sha256" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" +) + +func TestAssetName(t *testing.T) { + tests := []struct { + goos, goarch, want string + ok bool + }{ + {"linux", "amd64", "aforge-linux-amd64", true}, + {"linux", "arm64", "aforge-linux-arm64", true}, + {"darwin", "amd64", "aforge-darwin-amd64", true}, + {"darwin", "arm64", "aforge-darwin-arm64", true}, + {"windows", "amd64", "aforge-windows-amd64.exe", true}, + {"windows", "arm64", "aforge-windows-arm64.exe", true}, + {"freebsd", "riscv64", "", false}, + } + for _, tt := range tests { + t.Run(tt.goos+"_"+tt.goarch, func(t *testing.T) { + got, ok := AssetName(tt.goos, tt.goarch) + if got != tt.want || ok != tt.ok { + t.Fatalf("AssetName() = %q, %v; want %q, %v", got, ok, tt.want, tt.ok) + } + }) + } + if got := BinaryName("windows"); got != "aforge.exe" { + t.Fatalf("BinaryName(windows) = %q", got) + } +} + +func TestEnsureSkipEnvironmentIsNoOp(t *testing.T) { + home := filepath.Join(t.TempDir(), "uncreated") + t.Setenv(skipEnv, "1") + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests.Add(1) })) + t.Cleanup(server.Close) + if err := Ensure(Options{GOOS: "linux", GOARCH: "amd64", Home: home, BaseURL: server.URL}); err != nil { + t.Fatal(err) + } + assertNoRequestsOrFiles(t, requests.Load(), home) +} + +func TestEnsureUnsupportedPlatformIsNoOp(t *testing.T) { + home := filepath.Join(t.TempDir(), "uncreated") + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests.Add(1) })) + t.Cleanup(server.Close) + if err := Ensure(Options{GOOS: "freebsd", GOARCH: "riscv64", Home: home, BaseURL: server.URL}); err != nil { + t.Fatal(err) + } + assertNoRequestsOrFiles(t, requests.Load(), home) +} + +func TestEnsureInstallsGzippedAsset(t *testing.T) { + home := t.TempDir() + payload := []byte("aforge fixture") + server, _ := releaseServer(t, payload, "") + if err := Ensure(Options{GOOS: "linux", GOARCH: "amd64", Home: home, BaseURL: server.URL}); err != nil { + t.Fatal(err) + } + path := filepath.Join(home, "bin", "aforge") + got, err := os.ReadFile(path) + if err != nil || !bytes.Equal(got, payload) { + t.Fatalf("installed binary = %q, %v", got, err) + } + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != 0o755 { + t.Fatalf("installed mode = %v, %v", info.Mode(), err) + } + marker, err := os.ReadFile(filepath.Join(home, "bin", versionMarker)) + if err != nil || string(marker) != Version+"\n" { + t.Fatalf("marker = %q, %v", marker, err) + } +} + +func TestEnsureRejectsChecksumMismatch(t *testing.T) { + home := t.TempDir() + server, _ := releaseServer(t, []byte("tampered"), strings.Repeat("0", 64)) + err := Ensure(Options{GOOS: "linux", GOARCH: "amd64", Home: home, BaseURL: server.URL}) + if err == nil || !strings.Contains(err.Error(), "aforge-linux-amd64") { + t.Fatalf("error = %v", err) + } + assertNoInstall(t, home) +} + +func TestEnsureSkipsWhenMarkerMatchesVersion(t *testing.T) { + home := installedHome(t, Version) + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests.Add(1) })) + t.Cleanup(server.Close) + if err := Ensure(Options{GOOS: "linux", GOARCH: "amd64", Home: home, BaseURL: server.URL}); err != nil { + t.Fatal(err) + } + if requests.Load() != 0 { + t.Fatalf("requests = %d, want 0", requests.Load()) + } +} + +func TestEnsureReinstallsWhenMarkerDiffers(t *testing.T) { + home := installedHome(t, "old-version") + server, requests := releaseServer(t, []byte("fresh"), "") + if err := Ensure(Options{GOOS: "linux", GOARCH: "amd64", Home: home, BaseURL: server.URL}); err != nil { + t.Fatal(err) + } + if requests.Load() != 2 { + t.Fatalf("requests = %d, want 2", requests.Load()) + } + got, _ := os.ReadFile(filepath.Join(home, "bin", "aforge")) + if string(got) != "fresh" { + t.Fatalf("binary = %q", got) + } +} + +func TestEnsureForceReinstallsMatchingVersion(t *testing.T) { + home := installedHome(t, Version) + server, requests := releaseServer(t, []byte("forced"), "") + if err := Ensure(Options{GOOS: "linux", GOARCH: "amd64", Home: home, BaseURL: server.URL, Force: true}); err != nil { + t.Fatal(err) + } + if requests.Load() != 2 { + t.Fatalf("requests = %d, want 2", requests.Load()) + } +} + +func TestEnsureBaseURLPrecedence(t *testing.T) { + envServer, envRequests := releaseServer(t, []byte("from env"), "") + optionServer, optionRequests := releaseServer(t, []byte("from option"), "") + t.Setenv(baseURLEnv, envServer.URL+"/") + if err := Ensure(Options{GOOS: "linux", GOARCH: "amd64", Home: t.TempDir(), BaseURL: optionServer.URL + "/"}); err != nil { + t.Fatal(err) + } + if optionRequests.Load() != 2 || envRequests.Load() != 0 { + t.Fatalf("option requests = %d, env requests = %d", optionRequests.Load(), envRequests.Load()) + } + home := t.TempDir() + if err := Ensure(Options{GOOS: "linux", GOARCH: "amd64", Home: home}); err != nil { + t.Fatal(err) + } + if envRequests.Load() != 2 { + t.Fatalf("env requests = %d, want 2", envRequests.Load()) + } +} + +func TestEnsureRejectsOversizedDecompressedPayload(t *testing.T) { + home := t.TempDir() + payload := bytes.Repeat([]byte{'x'}, maxDecompressedBytes+1) + server, _ := releaseServer(t, payload, "") + err := Ensure(Options{GOOS: "linux", GOARCH: "amd64", Home: home, BaseURL: server.URL}) + if err == nil || !strings.Contains(err.Error(), fmt.Sprintf("exceeds %d bytes", maxDecompressedBytes)) { + t.Fatalf("error = %v", err) + } + assertNoInstall(t, home) +} + +func TestEnsureBestEffortWarnsOnce(t *testing.T) { + home := t.TempDir() + server := httptest.NewServer(http.NotFoundHandler()) + t.Cleanup(server.Close) + var warnings bytes.Buffer + if err := EnsureBestEffort(Options{GOOS: "linux", GOARCH: "amd64", Home: home, BaseURL: server.URL}, &warnings); err != nil { + t.Fatal(err) + } + want := "warning: aforge was not installed: download checksums: 404 Not Found\n" + if warnings.String() != want { + t.Fatalf("warning = %q, want %q", warnings.String(), want) + } + if err := EnsureBestEffort(Options{GOOS: "linux", GOARCH: "amd64", Home: t.TempDir(), BaseURL: server.URL}, nil); err != nil { + t.Fatal(err) + } +} + +func releaseServer(t *testing.T, payload []byte, checksum string) (*httptest.Server, *atomic.Int32) { + t.Helper() + if checksum == "" { + sum := sha256.Sum256(payload) + checksum = fmt.Sprintf("%x", sum) + } + var compressed bytes.Buffer + gz := gzip.NewWriter(&compressed) + if _, err := gz.Write(payload); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + switch r.URL.Path { + case "/checksums.txt": + for _, platform := range [][2]string{{"linux", "amd64"}, {"linux", "arm64"}, {"darwin", "amd64"}, {"darwin", "arm64"}, {"windows", "amd64"}, {"windows", "arm64"}} { + asset, _ := AssetName(platform[0], platform[1]) + _, _ = fmt.Fprintf(w, "%s %s\n", checksum, asset) + } + case "/aforge-linux-amd64.gz": + _, _ = w.Write(compressed.Bytes()) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + return server, &requests +} + +func installedHome(t *testing.T, marker string) string { + t.Helper() + home := t.TempDir() + binDir := filepath.Join(home, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(binDir, "aforge"), []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(binDir, versionMarker), []byte(marker+"\n"), 0o644); err != nil { + t.Fatal(err) + } + return home +} + +func assertNoRequestsOrFiles(t *testing.T, requests int32, home string) { + t.Helper() + if requests != 0 { + t.Fatalf("requests = %d, want 0", requests) + } + if _, err := os.Stat(home); !os.IsNotExist(err) { + t.Fatalf("home unexpectedly exists: %v", err) + } +} + +func assertNoInstall(t *testing.T, home string) { + t.Helper() + for _, name := range []string{"aforge", versionMarker} { + if _, err := os.Stat(filepath.Join(home, "bin", name)); !os.IsNotExist(err) { + t.Fatalf("%s unexpectedly exists: %v", name, err) + } + } +} diff --git a/control-plane/internal/aforge/lock_other.go b/control-plane/internal/aforge/lock_other.go new file mode 100644 index 000000000..fab235da8 --- /dev/null +++ b/control-plane/internal/aforge/lock_other.go @@ -0,0 +1,7 @@ +//go:build !unix + +package aforge + +func lockAforge(string) (func() error, error) { + return func() error { return nil }, nil +} diff --git a/control-plane/internal/aforge/lock_unix.go b/control-plane/internal/aforge/lock_unix.go new file mode 100644 index 000000000..ea908d7f9 --- /dev/null +++ b/control-plane/internal/aforge/lock_unix.go @@ -0,0 +1,20 @@ +//go:build unix + +package aforge + +import ( + "os" + "syscall" +) + +func lockAforge(path string) (func() error, error) { + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil { + _ = file.Close() + return nil, err + } + return file.Close, nil +} From 9f22a6ff59391685fbf117b74134b77df9fda931 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 17 Aug 2026 11:05:11 -0400 Subject: [PATCH 2/8] feat(cli): af aforge ensure, and teach the harness doctor about aforge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- control-plane/internal/cli/aforge.go | 28 ++++++ control-plane/internal/cli/harness_doctor.go | 78 ++++++++++++++- .../internal/cli/harness_doctor_test.go | 94 +++++++++++++++++++ control-plane/internal/cli/root.go | 1 + 4 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 control-plane/internal/cli/aforge.go diff --git a/control-plane/internal/cli/aforge.go b/control-plane/internal/cli/aforge.go new file mode 100644 index 000000000..2cf09a545 --- /dev/null +++ b/control-plane/internal/cli/aforge.go @@ -0,0 +1,28 @@ +package cli + +import ( + "github.com/Agent-Field/agentfield/control-plane/internal/aforge" + "github.com/spf13/cobra" +) + +func NewAforgeCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "aforge", + Short: "Manage the aforge coding-harness binary", + } + var force bool + ensureCmd := &cobra.Command{ + Use: "ensure", + Short: "Install or repair the pinned aforge coding-harness binary", + Args: cobra.NoArgs, + // Ensure, not EnsureBestEffort: silence is the right default when an + // install merely offers to provision aforge, but someone who asks for + // it by name is owed the failure. + RunE: func(_ *cobra.Command, _ []string) error { + return aforge.Ensure(aforge.Options{Force: force}) + }, + } + ensureCmd.Flags().BoolVar(&force, "force", false, "Re-download even when the pinned version is already installed") + cmd.AddCommand(ensureCmd) + return cmd +} diff --git a/control-plane/internal/cli/harness_doctor.go b/control-plane/internal/cli/harness_doctor.go index ae4e2245b..f8293207b 100644 --- a/control-plane/internal/cli/harness_doctor.go +++ b/control-plane/internal/cli/harness_doctor.go @@ -6,6 +6,8 @@ import ( "fmt" "os" "os/exec" + "path/filepath" + "runtime" "strings" "time" @@ -30,9 +32,17 @@ type harnessProviderSpec struct { Binary string InstallCommand string AuthEnvVars []string + // VersionArgs are tried in order until one produces output; empty means + // {"--version"}. + VersionArgs [][]string + // VersionOptional marks a provider that is usable without a working version + // probe. aforge's pinned build predates its `version` subcommand, so a + // present-and-executable binary is enough — the next release adds it. + VersionOptional bool } var harnessProviderSpecs = []harnessProviderSpec{ + {Name: "aforge", Binary: "aforge", InstallCommand: "af aforge ensure", AuthEnvVars: []string{"OPENROUTER_API_KEY"}, VersionArgs: [][]string{{"version"}, {"--version"}}, VersionOptional: true}, // claude-code has no Binary: the Python provider runs on the // claude_agent_sdk pip package (which bundles its own CLI), not on a // globally installed `claude` binary. See claudeCodeHealth. @@ -82,7 +92,7 @@ func newHarnessDoctorCommand() *cobra.Command { return nil }, } - cmd.Flags().StringSliceVar(&providers, "provider", nil, "Provider(s) to check: claude-code, codex, gemini, opencode") + cmd.Flags().StringSliceVar(&providers, "provider", nil, "Provider(s) to check: aforge, claude-code, codex, gemini, opencode") cmd.Flags().BoolVar(&jsonOut, "json", false, "Output structured JSON") return cmd } @@ -109,12 +119,19 @@ func buildHarnessDoctorReports(requested []string) ([]HarnessProviderHealth, err reports = append(reports, claudeCodeHealth(spec)) continue } - tool := checkTool(spec.Binary, "--version") + tool := probeHarnessBinary(spec) issues := []string{} + usable := tool.Available && tool.Version != "" if !tool.Available { issues = append(issues, "binary_not_found") } else if tool.Version == "" { - issues = append(issues, "version_probe_failed") + if spec.VersionOptional { + tool.Version = "unknown" + usable = true + issues = append(issues, "version_unavailable") + } else { + issues = append(issues, "version_probe_failed") + } } reports = append(reports, HarnessProviderHealth{ Provider: spec.Name, @@ -122,7 +139,7 @@ func buildHarnessDoctorReports(requested []string) ([]HarnessProviderHealth, err Installed: tool.Available, Version: tool.Version, Auth: harnessAuthStatus(spec.AuthEnvVars), - Usable: tool.Available && tool.Version != "", + Usable: usable, InstallCommand: spec.InstallCommand, AuthEnvVars: append([]string{}, spec.AuthEnvVars...), Issues: issues, @@ -131,6 +148,59 @@ func buildHarnessDoctorReports(requested []string) ([]HarnessProviderHealth, err return reports, nil } +// probeHarnessBinary resolves a provider binary and asks it for a version, +// trying each candidate argument set in order. +func probeHarnessBinary(spec harnessProviderSpec) ToolStatus { + path, err := exec.LookPath(spec.Binary) + if err != nil { + // `af aforge ensure` installs into AgentField's own bin directory, which + // the current shell may not have re-read into PATH yet. Looking there + // directly is what stops the doctor from calling a binary it just + // installed "not found". + home := os.Getenv("AGENTFIELD_HOME") + if home == "" { + userHome, homeErr := os.UserHomeDir() + if homeErr != nil { + return ToolStatus{} + } + home = filepath.Join(userHome, ".agentfield") + } + binary := spec.Binary + if runtime.GOOS == "windows" { + binary += ".exe" + } + candidate := filepath.Join(home, "bin", binary) + info, statErr := os.Stat(candidate) + if statErr != nil || !info.Mode().IsRegular() || (runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0) { + return ToolStatus{} + } + path = candidate + } + + argsSets := spec.VersionArgs + if len(argsSets) == 0 { + argsSets = [][]string{{"--version"}} + } + status := ToolStatus{Available: true, Path: path} + for _, args := range argsSets { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + out, runErr := exec.CommandContext(ctx, path, args...).CombinedOutput() + cancel() + // A non-zero exit is not a version, however much it printed: the pinned + // aforge build answers both `version` and `--version` with its whole + // usage text on exit 1, and accepting that output would file the usage + // banner as the installed version. + if runErr != nil { + continue + } + if version := strings.Split(strings.TrimSpace(string(out)), "\n")[0]; version != "" { + status.Version = version + break + } + } + return status +} + // claudeWrapperProbe asks a Python interpreter whether the claude_agent_sdk // package is importable, exiting zero either way so a non-zero exit always // means the interpreter itself is unusable. diff --git a/control-plane/internal/cli/harness_doctor_test.go b/control-plane/internal/cli/harness_doctor_test.go index 59f8b3bd2..cd4040c14 100644 --- a/control-plane/internal/cli/harness_doctor_test.go +++ b/control-plane/internal/cli/harness_doctor_test.go @@ -11,6 +11,22 @@ import ( "github.com/stretchr/testify/require" ) +func TestAforgeCommandHelp(t *testing.T) { + cmd := NewAforgeCommand() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"--help"}) + require.NoError(t, cmd.Execute()) + require.Contains(t, stdout.String(), "ensure") + + stdout.Reset() + cmd = NewAforgeCommand() + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"ensure", "--help"}) + require.NoError(t, cmd.Execute()) + require.Contains(t, stdout.String(), "--force") +} + func TestHarnessDoctorJSONReportsRequestedProvider(t *testing.T) { binDir := t.TempDir() writeHarnessTestBinary(t, binDir, "codex", "codex-cli 1.2.3") @@ -128,6 +144,84 @@ func TestHarnessDoctorRejectsUnknownProvider(t *testing.T) { require.ErrorContains(t, cmd.Execute(), "unknown harness provider") } +func TestProbeHarnessBinaryVersionBehavior(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture is Unix-only") + } + binDir := t.TempDir() + t.Setenv("PATH", binDir) + // Keep the managed-bin fallback out of these results: a developer machine + // with a real ~/.agentfield/bin/aforge would otherwise answer differently + // from CI. + t.Setenv("AGENTFIELD_HOME", t.TempDir()) + + // The pinned aforge build prints its whole usage banner and exits 1 for + // every version-ish argument, so the fixture does exactly that — a probe + // that only checked for output would file the banner as the version. + const usageOnExitOne = "echo 'aforge — build and revise task graphs' >&2; exit 1" + + t.Run("optional version", func(t *testing.T) { + writeHarnessTestScript(t, binDir, "optional", usageOnExitOne) + reports := reportsForTestSpec(t, harnessProviderSpec{Name: "optional", Binary: "optional", VersionArgs: [][]string{{"version"}, {"--version"}}, VersionOptional: true}) + require.True(t, reports[0].Usable) + require.Equal(t, "unknown", reports[0].Version) + require.Equal(t, []string{"version_unavailable"}, reports[0].Issues) + }) + + t.Run("required version", func(t *testing.T) { + writeHarnessTestScript(t, binDir, "required", usageOnExitOne) + reports := reportsForTestSpec(t, harnessProviderSpec{Name: "required", Binary: "required"}) + require.False(t, reports[0].Usable) + require.Empty(t, reports[0].Version) + require.Equal(t, []string{"version_probe_failed"}, reports[0].Issues) + }) + + t.Run("managed bin fallback", func(t *testing.T) { + home := t.TempDir() + t.Setenv("AGENTFIELD_HOME", home) + managed := filepath.Join(home, "bin") + require.NoError(t, os.MkdirAll(managed, 0o755)) + writeHarnessTestScript(t, managed, "offpath", "printf 'offpath 2.0\\n'") + reports := reportsForTestSpec(t, harnessProviderSpec{Name: "offpath", Binary: "offpath"}) + require.True(t, reports[0].Installed) + require.Equal(t, "offpath 2.0", reports[0].Version) + require.Equal(t, filepath.Join(managed, "offpath"), reports[0].Binary) + }) + + t.Run("ordered arguments", func(t *testing.T) { + writeHarnessTestScript(t, binDir, "ordered", "[ \"$1\" = --version ] && printf 'ordered 1.0\\n'") + reports := reportsForTestSpec(t, harnessProviderSpec{Name: "ordered", Binary: "ordered", VersionArgs: [][]string{{"version"}, {"--version"}}}) + require.True(t, reports[0].Usable) + require.Equal(t, "ordered 1.0", reports[0].Version) + }) +} + +func TestHarnessDoctorAforgeSpec(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + t.Setenv("AGENTFIELD_HOME", t.TempDir()) + reports, err := buildHarnessDoctorReports([]string{"aforge"}) + require.NoError(t, err) + require.Len(t, reports, 1) + require.Equal(t, "aforge", reports[0].Provider) + require.Equal(t, "af aforge ensure", reports[0].InstallCommand) + require.Equal(t, []string{"OPENROUTER_API_KEY"}, reports[0].AuthEnvVars) +} + +func reportsForTestSpec(t *testing.T, spec harnessProviderSpec) []HarnessProviderHealth { + t.Helper() + original := harnessProviderSpecs + harnessProviderSpecs = []harnessProviderSpec{spec} + t.Cleanup(func() { harnessProviderSpecs = original }) + reports, err := buildHarnessDoctorReports([]string{spec.Name}) + require.NoError(t, err) + return reports +} + +func writeHarnessTestScript(t *testing.T, dir, name, body string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755)) +} + func writeHarnessTestBinary(t *testing.T, dir, name, version string) { t.Helper() if runtime.GOOS == "windows" { diff --git a/control-plane/internal/cli/root.go b/control-plane/internal/cli/root.go index 0e2f5a9af..fdaa1bb4c 100644 --- a/control-plane/internal/cli/root.go +++ b/control-plane/internal/cli/root.go @@ -109,6 +109,7 @@ AI Agent? Run "af agent help" for structured JSON output optimized for programma // Add skill command — install/manage AgentField skills across coding agents RootCmd.AddCommand(NewSkillCommand()) RootCmd.AddCommand(NewFurrowCommand()) + RootCmd.AddCommand(NewAforgeCommand()) // Create service container for framework commands cfg := &config.Config{} // Use default config for now From 1197bba1b4c8477cdc8710d123700336b25fa767 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 17 Aug 2026 11:06:49 -0400 Subject: [PATCH 3/8] feat(install): provision aforge from the skill install and both shell installers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../internal/skillkit/aforge_install_test.go | 85 +++++++++++++++++++ control-plane/internal/skillkit/install.go | 7 ++ scripts/install.ps1 | 32 +++++++ scripts/install.sh | 47 ++++++++++ 4 files changed, 171 insertions(+) create mode 100644 control-plane/internal/skillkit/aforge_install_test.go diff --git a/control-plane/internal/skillkit/aforge_install_test.go b/control-plane/internal/skillkit/aforge_install_test.go new file mode 100644 index 000000000..614ede9ed --- /dev/null +++ b/control-plane/internal/skillkit/aforge_install_test.go @@ -0,0 +1,85 @@ +package skillkit + +import ( + "compress/gzip" + "crypto/sha256" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "sync/atomic" + "testing" +) + +func TestInstallAllEnsuresAforgeOnce(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("executable mode checks are not meaningful on Windows") + } + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("AGENTFIELD_HOME", home) + t.Setenv("CODEX_HOME", filepath.Join(home, "codex")) + t.Setenv("AGENTFIELD_SKIP_FURROW", "1") + + payload := []byte("aforge from skill install") + sum := sha256.Sum256(payload) + asset := fmt.Sprintf("aforge-%s-%s", runtime.GOOS, runtime.GOARCH) + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + switch r.URL.Path { + case "/checksums.txt": + _, _ = fmt.Fprintf(w, "%x %s\n", sum, asset) + case "/" + asset + ".gz": + gz := gzip.NewWriter(w) + _, _ = gz.Write(payload) + _ = gz.Close() + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + t.Setenv("AGENTFIELD_AFORGE_BASE_URL", server.URL) + + if _, err := InstallAll(InstallOptions{Targets: []string{"codex"}}); err != nil { + t.Fatalf("InstallAll: %v", err) + } + info, err := os.Stat(filepath.Join(home, "bin", "aforge")) + if err != nil { + t.Fatalf("stat provisioned aforge: %v", err) + } + if info.Mode().Perm()&0o111 == 0 { + t.Fatalf("provisioned aforge mode = %o, want executable", info.Mode().Perm()) + } + if got := requests.Load(); got != 2 { + t.Fatalf("HTTP requests = %d, want 2 (checksums + one asset)", got) + } +} + +func TestInstallAllDryRunDoesNotEnsureAforge(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("AGENTFIELD_HOME", home) + t.Setenv("CODEX_HOME", filepath.Join(home, "codex")) + t.Setenv("AGENTFIELD_SKIP_FURROW", "1") + + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + http.Error(w, "unexpected request", http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + t.Setenv("AGENTFIELD_AFORGE_BASE_URL", server.URL) + + if _, err := InstallAll(InstallOptions{Targets: []string{"codex"}, DryRun: true}); err != nil { + t.Fatalf("InstallAll(dry-run): %v", err) + } + if _, err := os.Stat(filepath.Join(home, "bin", "aforge")); !os.IsNotExist(err) { + t.Fatalf("stat aforge after dry-run: %v, want not exist", err) + } + if got := requests.Load(); got != 0 { + t.Fatalf("HTTP requests = %d, want 0", got) + } +} diff --git a/control-plane/internal/skillkit/install.go b/control-plane/internal/skillkit/install.go index 24456f07d..c121a2cb2 100644 --- a/control-plane/internal/skillkit/install.go +++ b/control-plane/internal/skillkit/install.go @@ -8,6 +8,7 @@ import ( "sort" "time" + "github.com/Agent-Field/agentfield/control-plane/internal/aforge" "github.com/Agent-Field/agentfield/control-plane/internal/furrow" ) @@ -201,6 +202,12 @@ func InstallAll(opts InstallOptions) ([]*InstallReport, error) { } reports = append(reports, report) } + if !opts.DryRun { + // `af skill install --all` is the one call every fresh install makes; + // keeping this out of install() avoids provisioning a 35MB binary once + // per catalog entry, and keeps --dry-run side-effect free. + _ = aforge.EnsureBestEffort(aforge.Options{}, os.Stderr) + } return reports, errors.Join(failures...) } diff --git a/scripts/install.ps1 b/scripts/install.ps1 index ae7c25361..939e7937d 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,6 +1,9 @@ +param([switch]$NoAforge) + # AgentField CLI Installer for Windows # Usage: iwr -useb https://agentfield.ai/install.ps1 | iex # Version pinning: $env:VERSION="v1.0.0"; iwr -useb https://agentfield.ai/install.ps1 | iex +# Skip aforge: $env:AFORGE_MODE="none"; iwr -useb https://agentfield.ai/install.ps1 | iex $ErrorActionPreference = "Stop" @@ -10,6 +13,8 @@ $InstallDir = if ($env:AGENTFIELD_INSTALL_DIR) { $env:AGENTFIELD_INSTALL_DIR } e $Version = if ($env:VERSION) { $env:VERSION } else { "latest" } $Verbose = if ($env:VERBOSE -eq "1") { $true } else { $false } $SkipPathConfig = if ($env:SKIP_PATH_CONFIG -eq "1") { $true } else { $false } +# Piped iwr | iex installs cannot pass switches, so retain an environment opt-out. +$AforgeMode = if ($NoAforge -or $env:AFORGE_MODE -eq 'none') { 'none' } else { 'auto' } # Colors function Write-ColorOutput { @@ -304,6 +309,31 @@ function Test-Installation { } } +function Install-Aforge { + param([string]$InstallDir) + + if ($AforgeMode -eq 'none') { + Write-Info "Skipping aforge install (AFORGE_MODE=none)" + return + } + + try { + Write-Info "Installing the aforge coding harness..." + # agentfield.exe, not the af.exe alias: Install-Binary creates the alias + # best-effort and warns when both the hardlink and the copy fail. + & (Join-Path $InstallDir 'agentfield.exe') aforge ensure + if ($LASTEXITCODE -eq 0) { + Write-Success "aforge coding harness installed" + } + else { + Write-Warning "aforge install reported an issue; the control plane is unaffected" + } + } + catch { + Write-Warning "aforge install reported an issue; the control plane is unaffected" + } +} + # Print success message function Write-SuccessMessage { Write-Host "" @@ -384,6 +414,8 @@ function Main { # Verify installation Test-Installation -InstallDir $InstallDir + Install-Aforge -InstallDir $InstallDir + # Print success message Write-SuccessMessage } diff --git a/scripts/install.sh b/scripts/install.sh index e3b6f68f4..3bf165645 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -42,6 +42,14 @@ SKILL_MODE="${SKILL_MODE:-all}" # TRAY_MODE=none. TRAY_MODE="${TRAY_MODE:-auto}" +# aforge coding-harness mode (auto | none) +# +# aforge is the one harness CLI AgentField distributes itself: agent nodes +# spawn it for LLM work, so a machine with af but no aforge fails at the first +# harness call. The download/verify/upgrade rules live in Go, so this installer +# only decides WHETHER to ask for it. Opt out with --no-aforge or AFORGE_MODE=none. +AFORGE_MODE="${AFORGE_MODE:-auto}" + # Extra flags forwarded to `af-tray install` (see --defer-restart / --take-over). # Bash 3.2 (macOS) treats an empty array under `set -u` as unset, so every # expansion below uses the +alternate form. @@ -95,6 +103,10 @@ parse_args() { TRAY_MODE="none" shift ;; + --no-aforge) + AFORGE_MODE="none" + shift + ;; --defer-restart) # Never restart a control plane that is already running; the newly # installed binary takes effect at the next restart. @@ -129,6 +141,7 @@ parse_args() { echo " not from 'curl … | bash')" echo " --no-tray Skip the macOS desktop tray / auto-start setup" echo " (control-plane binary only)" + echo " --no-aforge Skip installing the aforge coding-harness binary" echo " --defer-restart Update files but never restart a control" echo " plane that is already running" echo " --take-over Replace a launchd agent registered by a" @@ -143,6 +156,7 @@ parse_args() { echo " AGENTFIELD_INSTALL_DIR Custom install directory" echo " SKILL_MODE all (default) | all-targets | interactive | none" echo " TRAY_MODE auto (default, macOS only) | none" + echo " AFORGE_MODE auto (default) | none" exit 0 ;; *) @@ -612,6 +626,36 @@ install_skill() { esac } +# Install the pinned aforge coding-harness binary beside af. Delegated to +# `af aforge ensure` so the download, checksum verification and upgrade rules +# live in one place (control-plane/internal/aforge) and stay testable — +# mirroring how the skill install is delegated to `af skill install`. +# Best-effort: aforge is optional, so a failure here must never fail an +# install whose control plane is already working. +install_aforge() { + local install_dir="$1" + local af_bin="$install_dir/agentfield" + + if [[ "$AFORGE_MODE" == "none" ]]; then + printf "\n" + print_info "Skipping aforge install (AFORGE_MODE=none)" + return 0 + fi + + if [[ ! -x "$af_bin" ]]; then + print_warning "af binary not executable, skipping aforge install" + return 0 + fi + + printf "\n" + print_info "Installing the aforge coding harness..." + if "$af_bin" aforge ensure; then + print_success "aforge coding harness installed" + else + print_warning "aforge install reported an issue; the control plane is unaffected" + fi +} + # Install the AgentField desktop tray (menu-bar app) and register it — plus the # control plane — to auto-start via launchd. macOS + production channel only. # @@ -847,6 +891,9 @@ main() { # --all-skill-targets / --interactive-skill or SKILL_MODE. install_skill "$INSTALL_DIR" + # Provision the optional coding harness once the control plane is installed. + install_aforge "$INSTALL_DIR" + # Install the desktop tray + auto-start (macOS, production channel). Best-effort: # never fails the overall install, and never runs on Linux/headless/container hosts. install_tray "$os" "$arch" "$VERSION" From 3c2be1f39d4e7cb64867ac8a107f15b4f2a70cdb Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 17 Aug 2026 11:06:57 -0400 Subject: [PATCH 4/8] feat(desktop): ensure aforge on launch, once, without blocking startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- desktop/src/main/aforge-companion.test.ts | 111 ++++++++++++++++ desktop/src/main/aforge-companion.ts | 148 ++++++++++++++++++++++ desktop/src/main/index.ts | 8 ++ 3 files changed, 267 insertions(+) create mode 100644 desktop/src/main/aforge-companion.test.ts create mode 100644 desktop/src/main/aforge-companion.ts diff --git a/desktop/src/main/aforge-companion.test.ts b/desktop/src/main/aforge-companion.test.ts new file mode 100644 index 000000000..b8a9cad36 --- /dev/null +++ b/desktop/src/main/aforge-companion.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + type AforgeDeps, + ensureAforgeCompanion, + planAforge, + resetAforgeCompanion +} from './aforge-companion' + +const baseState = { cliCommand: '/managed/af', skipEnv: undefined, alreadyRan: false } + +describe('planAforge', () => { + it('skips only when AGENTFIELD_SKIP_AFORGE is exactly 1', () => { + expect(planAforge({ ...baseState, skipEnv: '1' })).toEqual({ + run: false, + reason: 'AGENTFIELD_SKIP_AFORGE=1 — skipping aforge provisioning' + }) + expect(planAforge({ ...baseState, skipEnv: '0' }).run).toBe(true) + expect(planAforge({ ...baseState, skipEnv: '' }).run).toBe(true) + expect(planAforge(baseState).run).toBe(true) + }) + + it('skips when the CLI is null, empty, or whitespace', () => { + for (const cliCommand of [null, '', ' ']) { + expect(planAforge({ ...baseState, cliCommand })).toEqual({ + run: false, + reason: 'no usable af CLI — skipping aforge provisioning' + }) + } + }) + + it('skips when aforge already ran', () => { + expect(planAforge({ ...baseState, alreadyRan: true })).toEqual({ + run: false, + reason: 'aforge already provisioned this launch' + }) + }) + + it('runs when no skip condition applies', () => { + expect(planAforge(baseState)).toEqual({ + run: true, + reason: 'provisioning aforge via af aforge ensure' + }) + }) + + it('applies skip env, already-ran, then missing-CLI precedence', () => { + expect(planAforge({ cliCommand: null, skipEnv: '1', alreadyRan: true }).reason).toContain( + 'AGENTFIELD_SKIP_AFORGE' + ) + expect(planAforge({ cliCommand: null, skipEnv: undefined, alreadyRan: true }).reason).toBe( + 'aforge already provisioned this launch' + ) + }) +}) + +function fakeDeps( + result: { code: number; stdout: string; stderr: string } = { + code: 0, + stdout: '', + stderr: '' + } +): AforgeDeps & { run: ReturnType } { + return { + run: vi.fn(async () => result), + cliCommand: () => '/managed/af', + env: () => undefined + } +} + +describe('ensureAforgeCompanion', () => { + beforeEach(() => resetAforgeCompanion()) + + it('runs aforge ensure exactly once on the happy path', async () => { + const deps = fakeDeps() + await expect(ensureAforgeCompanion(deps)).resolves.toEqual({ + ok: true, + message: 'aforge is provisioned' + }) + expect(deps.run).toHaveBeenCalledExactlyOnceWith('/managed/af', ['aforge', 'ensure']) + }) + + it('reports a non-zero exit with stderr', async () => { + const deps = fakeDeps({ code: 7, stdout: 'fallback', stderr: ' download failed \n' }) + const result = await ensureAforgeCompanion(deps) + expect(result.ok).toBe(false) + expect(result.message).toContain('exit 7') + expect(result.message).toContain('download failed') + }) + + it('captures a thrown runner error', async () => { + const deps = fakeDeps() + deps.run.mockRejectedValueOnce(new Error('spawn exploded')) + await expect(ensureAforgeCompanion(deps)).resolves.toMatchObject({ ok: false }) + }) + + it('does not run again on a second call in the same process', async () => { + const deps = fakeDeps() + await ensureAforgeCompanion(deps) + await expect(ensureAforgeCompanion(deps)).resolves.toEqual({ + ok: true, + message: 'aforge already provisioned this launch' + }) + expect(deps.run).toHaveBeenCalledTimes(1) + }) + + it('does not run when the injected environment opts out', async () => { + const deps = fakeDeps() + deps.env = (name) => (name === 'AGENTFIELD_SKIP_AFORGE' ? '1' : undefined) + await expect(ensureAforgeCompanion(deps)).resolves.toMatchObject({ ok: true }) + expect(deps.run).not.toHaveBeenCalled() + }) +}) diff --git a/desktop/src/main/aforge-companion.ts b/desktop/src/main/aforge-companion.ts new file mode 100644 index 000000000..4de60b96e --- /dev/null +++ b/desktop/src/main/aforge-companion.ts @@ -0,0 +1,148 @@ +// Provision the pinned aforge coding-harness binary on app launch, so a +// desktop-only install can run harness-backed agents without anyone ever +// running the curl installer. Unlike tray-companion.ts this ships no bundled +// payload: it shells out to `af aforge ensure`, which owns the download, +// checksum verification and upgrade rules (control-plane/internal/aforge). +// One code path, and a version bump in af upgrades every surface at once. +// +// Two moving parts, kept apart so the decision is unit-testable: +// 1. planAforge() — pure: given the resolved af command, the skip env var and +// whether we already ran, decide whether to shell out at all. +// 2. ensureAforgeCompanion() — the effect, driven by injected deps so tests +// never spawn anything. +// +// Best-effort by construction: every failure resolves to { ok: false }, never +// throws, so a dead network can't delay or break startup. + +import { spawn } from 'node:child_process' +import { getCliCommand } from './cli' +import { childEnv } from './env' + +const ENSURE_TIMEOUT_MS = 5 * 60 * 1_000 + +/** What ensureAforge should do, decided purely from the observed state. */ +export interface AforgeState { + /** The `af` command the app resolved at startup, or null when none is usable. */ + cliCommand: string | null + /** Value of AGENTFIELD_SKIP_AFORGE in the app's environment. */ + skipEnv: string | undefined + /** ensureAforge already ran in this process. */ + alreadyRan: boolean +} + +export interface AforgePlan { + run: boolean + reason: string +} + +export function planAforge(s: AforgeState): AforgePlan { + if (s.skipEnv === '1') { + return { run: false, reason: 'AGENTFIELD_SKIP_AFORGE=1 — skipping aforge provisioning' } + } + if (s.alreadyRan) { + return { run: false, reason: 'aforge already provisioned this launch' } + } + if (s.cliCommand === null || s.cliCommand.trim() === '') { + return { run: false, reason: 'no usable af CLI — skipping aforge provisioning' } + } + return { run: true, reason: 'provisioning aforge via af aforge ensure' } +} + +export interface AforgeDeps { + /** Run a command to completion; must never reject (resolve code=-1 on spawn error). */ + run: ( + command: string, + args: string[] + ) => Promise<{ code: number; stdout: string; stderr: string }> + /** The af command to drive — `getCliCommand()` from './cli' in production. */ + cliCommand: () => string | null + /** Environment lookup, injected so tests don't mutate process.env. */ + env: (name: string) => string | undefined +} + +function realRun( + command: string, + args: string[] +): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise((resolve) => { + let stdout = '' + let stderr = '' + let settled = false + const done = (code: number) => { + if (settled) return + settled = true + resolve({ code, stdout, stderr }) + } + + try { + const child = spawn(command, args, { windowsHide: true, env: childEnv() }) + // aforge is roughly a 35 MB download, so allow slow connections five minutes. + const timer = setTimeout(() => { + child.kill() + done(-1) + }, ENSURE_TIMEOUT_MS) + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf8') + }) + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8') + }) + child.on('error', () => { + clearTimeout(timer) + done(-1) + }) + child.on('close', (code) => { + clearTimeout(timer) + done(code ?? -1) + }) + } catch { + done(-1) + } + }) +} + +export function defaultAforgeDeps(): AforgeDeps { + return { + run: realRun, + cliCommand: getCliCommand, + env: (name) => process.env[name] + } +} + +export interface AforgeResult { + ok: boolean + message: string +} + +let alreadyRan = false + +export async function ensureAforgeCompanion( + deps: AforgeDeps = defaultAforgeDeps() +): Promise { + try { + const cliCommand = deps.cliCommand() + const plan = planAforge({ + cliCommand, + skipEnv: deps.env('AGENTFIELD_SKIP_AFORGE'), + alreadyRan + }) + if (!plan.run) return { ok: true, message: plan.reason } + + alreadyRan = true + const result = await deps.run(cliCommand as string, ['aforge', 'ensure']) + if (result.code === 0) return { ok: true, message: 'aforge is provisioned' } + + const detail = (result.stderr || result.stdout).trim() + return { + ok: false, + message: `af aforge ensure failed (exit ${result.code}): ${detail}` + } + } catch (err) { + return { ok: false, message: `af aforge ensure failed: ${String(err)}` } + } +} + +/** Reset the once-per-launch latch (tests only). */ +export function resetAforgeCompanion(): void { + alreadyRan = false +} diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index 30107a7c8..bc275a066 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -7,6 +7,7 @@ import { DEEP_LINK_SCHEME, type View, deepLinkFromArgv, parseDeepLink } from '.. import type { DesktopSettings } from '../shared/types' import { getBaseUrl, getSnapshot, setActiveControlPlanePort } from './agentfield' import { type AgentAction, runAgentAction, startControlPlane, uninstallAgent } from './agents' +import { ensureAforgeCompanion } from './aforge-companion' import { runAutostart } from './autostart' import { testCloudConnection, applyConnectionProfile } from './cloud' import { isCloudActive } from './connection' @@ -359,6 +360,13 @@ function main(): void { // (it needs the managed bin dir to exist) and non-blocking, like syncSkills. syncTray(settings.trayCompanion) + // The pinned aforge harness binary lands beside af in ~/.agentfield/bin, so a + // desktop-only install can run harness-backed agents. Fire-and-forget: a failed + // download must never delay or break app startup. + void ensureAforgeCompanion().then((r) => + console.log(`aforge companion: ${r.ok ? 'ok' : 'FAILED'} — ${r.message}`) + ) + // The snapshot carries the last skill-sync result along with the control- // plane view, so the renderer's existing 5s poll keeps the dashboard's // skill state honest without a channel (or a loop) of its own. From 349ac6f21b4812aaf0aa05f644a8f7c83650c1db Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 17 Aug 2026 11:07:05 -0400 Subject: [PATCH 5/8] feat(docker): ship aforge in the images that actually run agent nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../docker/Dockerfile.control-plane-cloud | 40 +++++++++++++++++++ deployments/docker/Dockerfile.go-agent | 38 ++++++++++++++++++ deployments/docker/Dockerfile.python-agent | 36 +++++++++++++++++ 3 files changed, 114 insertions(+) diff --git a/deployments/docker/Dockerfile.control-plane-cloud b/deployments/docker/Dockerfile.control-plane-cloud index 5b059535a..89406ea2b 100644 --- a/deployments/docker/Dockerfile.control-plane-cloud +++ b/deployments/docker/Dockerfile.control-plane-cloud @@ -51,6 +51,39 @@ RUN cd control-plane && \ if [ "${TARGETARCH}" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc; else export CC=gcc; fi && \ GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -tags "embedded sqlite_fts5" -o /app/bin/af ./cmd/af +# aforge — the pinned coding-harness binary AgentField's harness providers +# spawn (`aforge exec --json -w `). Fetched in its own stage so the +# runtime layer is a single COPY and the download never drags curl/apt state +# into the final image. +# +# Soft fetch, hard verify: the download host goes live with the website +# deploy, so a 404 degrades to "this image ships without aforge" rather than +# breaking every image build in the meantime. A download that DOES land is +# always checksum-verified — checksums.txt carries the sha256 of the +# UNCOMPRESSED binary, so the hash is taken after gunzip. +# +# Pin overrides (both are build args): AFORGE_BASE_URL, AFORGE_VERSION. +FROM debian:bookworm-slim AS aforge-dist +ARG TARGETARCH +ARG AFORGE_BASE_URL=https://agentfield.ai/downloads/aforge +ARG AFORGE_VERSION=build-9b3ff482de3f +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /out +RUN set -eu; \ + if curl -fsSL "${AFORGE_BASE_URL}/${AFORGE_VERSION}/aforge-linux-${TARGETARCH}.gz" -o aforge.gz \ + && curl -fsSL "${AFORGE_BASE_URL}/${AFORGE_VERSION}/checksums.txt" -o checksums.txt; then \ + gunzip -c aforge.gz > "aforge-linux-${TARGETARCH}"; \ + grep " aforge-linux-${TARGETARCH}\$" checksums.txt | sha256sum -c -; \ + chmod 0755 "aforge-linux-${TARGETARCH}"; \ + mv "aforge-linux-${TARGETARCH}" aforge; \ + else \ + echo "WARNING: aforge ${AFORGE_VERSION} is not available at ${AFORGE_BASE_URL}; this image ships without it" >&2; \ + fi; \ + rm -f aforge.gz checksums.txt + # Toolchain donors — same arch as the runtime stage (no --platform pin). FROM node:22-bookworm-slim AS node-dist FROM golang:1.25-bookworm AS go-dist @@ -102,6 +135,13 @@ COPY --from=go-builder /app/control-plane/config /etc/agentfield/config # binary changes every release, so everything after it rebuilds fresh. RUN npm install -g opencode-ai +# aforge harness binary (see the aforge-dist stage). Copying the stage's +# output DIRECTORY, not a fixed file path, is deliberate: when the pinned +# build could not be fetched the directory is empty and this COPY is a +# no-op, instead of failing the build or planting a zero-byte `aforge` on +# PATH that `af harness doctor` would report as installed. +COPY --from=aforge-dist /out/ /usr/local/bin/ + # Everything stateful (SQLite, BoltDB, installed.yaml, secrets keyring, # agent package dirs) lives under one mount point. ENV AGENTFIELD_HOME=/data diff --git a/deployments/docker/Dockerfile.go-agent b/deployments/docker/Dockerfile.go-agent index 8913f5fa8..0d74979ef 100644 --- a/deployments/docker/Dockerfile.go-agent +++ b/deployments/docker/Dockerfile.go-agent @@ -1,3 +1,36 @@ +# aforge — the pinned coding-harness binary AgentField's harness providers +# spawn (`aforge exec --json -w `). Fetched in its own stage so the +# runtime layer is a single COPY and the download never drags curl/apt state +# into the final image. +# +# Soft fetch, hard verify: the download host goes live with the website +# deploy, so a 404 degrades to "this image ships without aforge" rather than +# breaking every image build in the meantime. A download that DOES land is +# always checksum-verified — checksums.txt carries the sha256 of the +# UNCOMPRESSED binary, so the hash is taken after gunzip. +# +# Pin overrides (both are build args): AFORGE_BASE_URL, AFORGE_VERSION. +FROM debian:bookworm-slim AS aforge-dist +ARG TARGETARCH +ARG AFORGE_BASE_URL=https://agentfield.ai/downloads/aforge +ARG AFORGE_VERSION=build-9b3ff482de3f +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /out +RUN set -eu; \ + if curl -fsSL "${AFORGE_BASE_URL}/${AFORGE_VERSION}/aforge-linux-${TARGETARCH}.gz" -o aforge.gz \ + && curl -fsSL "${AFORGE_BASE_URL}/${AFORGE_VERSION}/checksums.txt" -o checksums.txt; then \ + gunzip -c aforge.gz > "aforge-linux-${TARGETARCH}"; \ + grep " aforge-linux-${TARGETARCH}\$" checksums.txt | sha256sum -c -; \ + chmod 0755 "aforge-linux-${TARGETARCH}"; \ + mv "aforge-linux-${TARGETARCH}" aforge; \ + else \ + echo "WARNING: aforge ${AFORGE_VERSION} is not available at ${AFORGE_BASE_URL}; this image ships without it" >&2; \ + fi; \ + rm -f aforge.gz checksums.txt + FROM golang:1.25-alpine WORKDIR /workspace @@ -7,6 +40,11 @@ COPY sdk/go /tmp/go-sdk RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/go/pkg/mod \ cd /tmp/go-sdk && go mod download && go test ./... +# aforge is a statically linked ELF binary, so the debian-built asset runs +# unchanged on this musl/alpine base. See the aforge-dist stage for why +# this copies a directory, not a file. +COPY --from=aforge-dist /out/ /usr/local/bin/ + ENV GO111MODULE=on CMD ["sh", "-c", "echo 'AgentField Go agent image ready. Mount or COPY your agent source and override CMD to run it.' && tail -f /dev/null"] diff --git a/deployments/docker/Dockerfile.python-agent b/deployments/docker/Dockerfile.python-agent index dedae7183..430f14a83 100644 --- a/deployments/docker/Dockerfile.python-agent +++ b/deployments/docker/Dockerfile.python-agent @@ -1,3 +1,36 @@ +# aforge — the pinned coding-harness binary AgentField's harness providers +# spawn (`aforge exec --json -w `). Fetched in its own stage so the +# runtime layer is a single COPY and the download never drags curl/apt state +# into the final image. +# +# Soft fetch, hard verify: the download host goes live with the website +# deploy, so a 404 degrades to "this image ships without aforge" rather than +# breaking every image build in the meantime. A download that DOES land is +# always checksum-verified — checksums.txt carries the sha256 of the +# UNCOMPRESSED binary, so the hash is taken after gunzip. +# +# Pin overrides (both are build args): AFORGE_BASE_URL, AFORGE_VERSION. +FROM debian:bookworm-slim AS aforge-dist +ARG TARGETARCH +ARG AFORGE_BASE_URL=https://agentfield.ai/downloads/aforge +ARG AFORGE_VERSION=build-9b3ff482de3f +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /out +RUN set -eu; \ + if curl -fsSL "${AFORGE_BASE_URL}/${AFORGE_VERSION}/aforge-linux-${TARGETARCH}.gz" -o aforge.gz \ + && curl -fsSL "${AFORGE_BASE_URL}/${AFORGE_VERSION}/checksums.txt" -o checksums.txt; then \ + gunzip -c aforge.gz > "aforge-linux-${TARGETARCH}"; \ + grep " aforge-linux-${TARGETARCH}\$" checksums.txt | sha256sum -c -; \ + chmod 0755 "aforge-linux-${TARGETARCH}"; \ + mv "aforge-linux-${TARGETARCH}" aforge; \ + else \ + echo "WARNING: aforge ${AFORGE_VERSION} is not available at ${AFORGE_BASE_URL}; this image ships without it" >&2; \ + fi; \ + rm -f aforge.gz checksums.txt + FROM python:3.11-slim ENV PYTHONDONTWRITEBYTECODE=1 \ @@ -11,4 +44,7 @@ RUN pip install --no-cache-dir --upgrade pip && \ pip install --no-cache-dir /tmp/python-sdk && \ rm -rf /tmp/python-sdk +# See the aforge-dist stage for why this copies a directory, not a file. +COPY --from=aforge-dist /out/ /usr/local/bin/ + CMD ["python", "-c", "print('AgentField Python agent image ready. Override CMD to run your agent.')"] From 17435bf29b09137f479966d16ee144ff2bbf51da Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 17 Aug 2026 11:07:11 -0400 Subject: [PATCH 6/8] docs(aforge): point every install hint at `af aforge ensure` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 4 ++++ control-plane/.env.example | 9 +++++++++ docs/ENVIRONMENT_VARIABLES.md | 18 ++++++++++++++++++ docs/harness-providers.md | 15 ++++++++++++++- sdk/python/agentfield/harness/_availability.py | 7 ++++--- .../test_harness_provider_availability.py | 2 +- 6 files changed, 50 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ce45d034b..e65e6d51d 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,10 @@ https://github.com/user-attachments/assets/9fb7b1cf-26de-4b9b-9ba2-917252cc26ec curl -fsSL https://agentfield.ai/install.sh | bash ``` +The installer also drops the `aforge` coding harness beside `af` in +`~/.agentfield/bin`, so harness-backed agents work out of the box; skip it with +`--no-aforge`. + On macOS the installer also registers the control plane to start at login (under launchd) and adds a menu-bar icon. Stop it with `af service stop` or the menu-bar icon — a plain `kill` looks like a crash and it restarts. `af service status` diff --git a/control-plane/.env.example b/control-plane/.env.example index 75187c6aa..a454d7b90 100644 --- a/control-plane/.env.example +++ b/control-plane/.env.example @@ -79,6 +79,15 @@ AGENTFIELD_STORAGE_MODE=local # AGENTFIELD_LOG_BUFFER_BYTES=4194304 # AGENTFIELD_LOG_MAX_LINE_BYTES=16384 +# aforge coding harness (provisioned by `af aforge ensure`, the curl installer, +# the desktop app and the agent images into $AGENTFIELD_HOME/bin/aforge). +# Whole-base override for the download host — the pinned version is NOT appended, +# so point it at a directory that already contains the assets + checksums.txt. +# AGENTFIELD_AFORGE_BASE_URL=https://agentfield.ai/downloads/aforge/build-9b3ff482de3f +# Set to 1 to make every aforge provisioning step a no-op (air-gapped hosts, or +# images that vendor their own harness). +# AGENTFIELD_SKIP_AFORGE=1 + # Development/Debug # GIN_MODE=debug # LOG_LEVEL=info diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 57e2d11ac..288bdcd75 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -12,6 +12,24 @@ AgentField uses Viper with the prefix `AGENTFIELD` and maps nested config keys u - `AGENTFIELD_CONFIG_FILE` (optional): Path to `agentfield.yaml` (in containers this is typically `/etc/agentfield/config/agentfield.yaml`). - `AGENTFIELD_HOME` (recommended in containers): Base directory where AgentField stores local state (SQLite DB, Bolt DB, keys, logs). In Kubernetes, mount a PVC and set `AGENTFIELD_HOME=/data`. +### Coding harness (aforge) + +`af` distributes one harness CLI itself: `aforge`. Every install surface (the curl +installer, `af skill install --all`, the desktop app on launch, and the +`python-agent` / `go-agent` / cloud control-plane images) provisions the pinned +build into `$AGENTFIELD_HOME/bin/aforge` (`~/.agentfield/bin` by default), verified +against the published sha256 of the uncompressed binary. + +- `AGENTFIELD_AFORGE_BASE_URL` (optional): Whole-base override for the download host, + e.g. an internal mirror. The pinned version is **not** appended — the URL must + already point at a directory holding `aforge--[.exe].gz` and + `checksums.txt`. Default: `https://agentfield.ai/downloads/aforge/`. +- `AGENTFIELD_SKIP_AFORGE` (optional): Set to `1` to make every aforge provisioning + step a no-op — air-gapped hosts, or images that vendor their own harness. + +Shell-installer equivalents: `--no-aforge` / `AFORGE_MODE=none` (`scripts/install.sh`), +`-NoAforge` / `$env:AFORGE_MODE='none'` (`scripts/install.ps1`). + ### Storage AgentField supports: diff --git a/docs/harness-providers.md b/docs/harness-providers.md index b89112d2f..62995a11d 100644 --- a/docs/harness-providers.md +++ b/docs/harness-providers.md @@ -8,7 +8,7 @@ starting a workflow. | Provider | Python extra | Required CLI | Authentication | | --- | --- | --- | --- | -| `aforge` | None | `aforge` | `OPENROUTER_API_KEY` | +| `aforge` | None | `aforge` (`af aforge ensure`) | `OPENROUTER_API_KEY` | | Claude Code | `agentfield[harness-claude]` | Bundled by `claude-agent-sdk` | Claude login or `ANTHROPIC_API_KEY` | | Codex | `agentfield[harness-codex]` | `codex` | Codex login or `OPENAI_API_KEY` | | Gemini | None | `gemini` | Gemini login, `GEMINI_API_KEY`, or `GOOGLE_API_KEY` | @@ -20,6 +20,19 @@ Install every Python wrapper with: pip install 'agentfield[harness-all]' ``` +`aforge` is the one CLI AgentField distributes itself. Every install surface +provisions it beside `af` in `~/.agentfield/bin` — the curl installer, the +desktop app on launch, and the `python-agent` / `go-agent` / cloud control-plane +images. To install or repair it by hand: + +```bash +af aforge ensure # --force re-downloads even when already current +``` + +The pinned build, its download host and the opt-out are documented under +`AGENTFIELD_AFORGE_BASE_URL` / `AGENTFIELD_SKIP_AFORGE` in +[docs/ENVIRONMENT_VARIABLES.md](ENVIRONMENT_VARIABLES.md). + The extras install Python wrappers. They do not replace the runtime preflight: Aforge and Gemini are CLI-only, and Codex or OpenCode may still require a separately available executable depending on the wrapper and platform. diff --git a/sdk/python/agentfield/harness/_availability.py b/sdk/python/agentfield/harness/_availability.py index 809d7633b..4f66ab68c 100644 --- a/sdk/python/agentfield/harness/_availability.py +++ b/sdk/python/agentfield/harness/_availability.py @@ -18,9 +18,10 @@ class ProviderSpec: "aforge": ProviderSpec( binary="aforge", version_args=("version",), - install_command=( - "go build -o aforge ./cmd/aforge (https://github.com/Agent-Field/aforge-v2)" - ), + # `af` installs aforge beside itself in ~/.agentfield/bin (see + # control-plane/internal/aforge); the curl installer, the desktop app + # and the agent images all converge on that one command. + install_command="af aforge ensure", auth_env_vars=("OPENROUTER_API_KEY",), ), "codex": ProviderSpec( diff --git a/sdk/python/tests/test_harness_provider_availability.py b/sdk/python/tests/test_harness_provider_availability.py index 76667450b..fa420376d 100644 --- a/sdk/python/tests/test_harness_provider_availability.py +++ b/sdk/python/tests/test_harness_provider_availability.py @@ -17,7 +17,7 @@ @pytest.mark.parametrize( ("provider", "name", "install_command"), [ - (AforgeProvider(bin_path="aforge-missing"), "aforge", "aforge-v2"), + (AforgeProvider(bin_path="aforge-missing"), "aforge", "af aforge ensure"), (CodexProvider(bin_path="codex-missing"), "codex", "@openai/codex"), ( OpenCodeProvider(bin_path="opencode-missing"), From aa24424da9bae3e4ef30ab4146cce7a5262f3b52 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 17 Aug 2026 11:42:44 -0400 Subject: [PATCH 7/8] test(aforge): cover the provisioner's failure paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- control-plane/internal/aforge/ensure_test.go | 162 ++++++++++++++++++ .../internal/aforge/ensure_unix_test.go | 76 ++++++++ .../internal/cli/harness_doctor_test.go | 9 + 3 files changed, 247 insertions(+) create mode 100644 control-plane/internal/aforge/ensure_unix_test.go diff --git a/control-plane/internal/aforge/ensure_test.go b/control-plane/internal/aforge/ensure_test.go index d962683b7..f18182a8a 100644 --- a/control-plane/internal/aforge/ensure_test.go +++ b/control-plane/internal/aforge/ensure_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "strings" "sync/atomic" "testing" @@ -85,6 +86,66 @@ func TestEnsureInstallsGzippedAsset(t *testing.T) { } } +func TestEnsureDefaultsToRuntimePlatformAndAgentfieldHome(t *testing.T) { + asset, supported := AssetName(runtime.GOOS, runtime.GOARCH) + if !supported { + t.Skipf("unsupported test platform %s/%s", runtime.GOOS, runtime.GOARCH) + } + home := t.TempDir() + t.Setenv("AGENTFIELD_HOME", home) + payload := []byte("native aforge fixture") + server := releaseServerForAsset(t, asset, payload) + if err := Ensure(Options{BaseURL: server.URL}); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(home, "bin", BinaryName(runtime.GOOS))) + if err != nil || !bytes.Equal(got, payload) { + t.Fatalf("installed binary = %q, %v", got, err) + } +} + +func TestAgentfieldHomePrecedenceAndFallback(t *testing.T) { + override := t.TempDir() + environment := t.TempDir() + t.Setenv("AGENTFIELD_HOME", environment) + got, err := agentfieldHome(override) + if err != nil || got != override { + t.Fatalf("agentfieldHome(override) = %q, %v", got, err) + } + got, err = agentfieldHome("") + if err != nil || got != environment { + t.Fatalf("agentfieldHome(environment) = %q, %v", got, err) + } + + t.Setenv("AGENTFIELD_HOME", "") + userHome := t.TempDir() + t.Setenv("HOME", userHome) + if runtime.GOOS == "windows" { + t.Setenv("USERPROFILE", userHome) + } + got, err = agentfieldHome("") + want := filepath.Join(userHome, ".agentfield") + if err != nil || got != want { + t.Fatalf("agentfieldHome(user home) = %q, %v; want %q", got, err, want) + } +} + +func TestAgentfieldHomeReportsMissingUserHome(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("UserHomeDir uses additional Windows environment variables") + } + t.Setenv("AGENTFIELD_HOME", "") + t.Setenv("HOME", "") + _, err := agentfieldHome("") + if err == nil || !strings.Contains(err.Error(), "resolve home directory") { + t.Fatalf("error = %v", err) + } + err = Ensure(Options{GOOS: "linux", GOARCH: "amd64"}) + if err == nil || !strings.Contains(err.Error(), "resolve home directory") { + t.Fatalf("Ensure error = %v", err) + } +} + func TestEnsureRejectsChecksumMismatch(t *testing.T) { home := t.TempDir() server, _ := releaseServer(t, []byte("tampered"), strings.Repeat("0", 64)) @@ -153,6 +214,80 @@ func TestEnsureBaseURLPrecedence(t *testing.T) { } } +func TestEnsureUsesDefaultBaseURL(t *testing.T) { + t.Setenv(baseURLEnv, "") + client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + if !strings.HasPrefix(request.URL.String(), defaultBaseURL+"/") { + t.Fatalf("request URL = %q, want prefix %q", request.URL, defaultBaseURL+"/") + } + return nil, fmt.Errorf("fixture transport failure") + })} + err := Ensure(Options{GOOS: "linux", GOARCH: "amd64", Home: t.TempDir(), Client: client}) + if err == nil || !strings.Contains(err.Error(), "fixture transport failure") { + t.Fatalf("error = %v", err) + } +} + +func TestDownloadErrors(t *testing.T) { + closed := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + closed.Close() + if _, err := download(http.DefaultClient, closed.URL); err == nil { + t.Fatal("download transport error = nil") + } +} + +func TestDownloadGzipErrors(t *testing.T) { + var valid bytes.Buffer + gz := gzip.NewWriter(&valid) + _, _ = gz.Write(bytes.Repeat([]byte("truncated payload"), 100)) + _ = gz.Close() + tests := []struct { + name string + status int + body []byte + }{ + {name: "non-200", status: http.StatusNotFound}, + {name: "invalid header", status: http.StatusOK, body: []byte("not gzip")}, + {name: "truncated stream", status: http.StatusOK, body: valid.Bytes()[:valid.Len()-4]}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(test.status) + _, _ = w.Write(test.body) + })) + t.Cleanup(server.Close) + if _, err := downloadGzip(http.DefaultClient, server.URL); err == nil { + t.Fatal("downloadGzip error = nil") + } + }) + } + closed := httptest.NewServer(http.NotFoundHandler()) + closed.Close() + if _, err := downloadGzip(http.DefaultClient, closed.URL); err == nil { + t.Fatal("downloadGzip transport error = nil") + } +} + +func TestChecksumForRejectsInvalidAndMissingChecksums(t *testing.T) { + asset := "aforge-linux-amd64" + tests := []struct { + name string + data string + }{ + {name: "malformed hex", data: strings.Repeat("z", 64) + " " + asset}, + {name: "wrong length", data: "0123456789 " + asset}, + {name: "missing asset", data: strings.Repeat("0", 64) + " another-asset"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := checksumFor([]byte(test.data), asset); err == nil { + t.Fatal("checksumFor error = nil") + } + }) + } +} + func TestEnsureRejectsOversizedDecompressedPayload(t *testing.T) { home := t.TempDir() payload := bytes.Repeat([]byte{'x'}, maxDecompressedBytes+1) @@ -214,6 +349,33 @@ func releaseServer(t *testing.T, payload []byte, checksum string) (*httptest.Ser return server, &requests } +func releaseServerForAsset(t *testing.T, asset string, payload []byte) *httptest.Server { + t.Helper() + sum := sha256.Sum256(payload) + var compressed bytes.Buffer + gz := gzip.NewWriter(&compressed) + _, _ = gz.Write(payload) + _ = gz.Close() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/checksums.txt": + _, _ = fmt.Fprintf(w, "%x %s\n", sum, asset) + case "/" + asset + ".gz": + _, _ = w.Write(compressed.Bytes()) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + return server +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return fn(request) +} + func installedHome(t *testing.T, marker string) string { t.Helper() home := t.TempDir() diff --git a/control-plane/internal/aforge/ensure_unix_test.go b/control-plane/internal/aforge/ensure_unix_test.go new file mode 100644 index 000000000..2e80c6efa --- /dev/null +++ b/control-plane/internal/aforge/ensure_unix_test.go @@ -0,0 +1,76 @@ +//go:build unix + +package aforge + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEnsureFailsToCreateBinDirectory(t *testing.T) { + home := t.TempDir() + if err := os.WriteFile(filepath.Join(home, "bin"), []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + err := Ensure(Options{GOOS: "linux", GOARCH: "amd64", Home: home}) + if err == nil || !strings.Contains(err.Error(), "create aforge bin directory") { + t.Fatalf("error = %v", err) + } +} + +func TestEnsureFailsToLockInstallation(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can write through directory permissions") + } + home := t.TempDir() + binDir := filepath.Join(home, "bin") + if err := os.Mkdir(binDir, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Chmod(binDir, 0o700); err != nil { + t.Errorf("restore bin directory permissions: %v", err) + } + }) + err := Ensure(Options{GOOS: "linux", GOARCH: "amd64", Home: home}) + if err == nil || !strings.Contains(err.Error(), "lock aforge installation") { + t.Fatalf("error = %v", err) + } +} + +func TestLockAforgeRejectsDirectoryLockPath(t *testing.T) { + path := filepath.Join(t.TempDir(), "lock") + if err := os.Mkdir(path, 0o755); err != nil { + t.Fatal(err) + } + if _, err := lockAforge(path); err == nil { + t.Fatal("lockAforge error = nil") + } +} + +func TestEnsureReportsRenameAndMarkerFailures(t *testing.T) { + t.Run("rename", func(t *testing.T) { + home := t.TempDir() + if err := os.MkdirAll(filepath.Join(home, "bin", "aforge", "child"), 0o755); err != nil { + t.Fatal(err) + } + server, _ := releaseServer(t, []byte("fixture"), "") + err := Ensure(Options{GOOS: "linux", GOARCH: "amd64", Home: home, BaseURL: server.URL}) + if err == nil || !strings.Contains(err.Error(), "install aforge") { + t.Fatalf("error = %v", err) + } + }) + t.Run("marker", func(t *testing.T) { + home := t.TempDir() + if err := os.MkdirAll(filepath.Join(home, "bin", versionMarker), 0o755); err != nil { + t.Fatal(err) + } + server, _ := releaseServer(t, []byte("fixture"), "") + err := Ensure(Options{GOOS: "linux", GOARCH: "amd64", Home: home, BaseURL: server.URL}) + if err == nil || !strings.Contains(err.Error(), "record aforge version") { + t.Fatalf("error = %v", err) + } + }) +} diff --git a/control-plane/internal/cli/harness_doctor_test.go b/control-plane/internal/cli/harness_doctor_test.go index cd4040c14..586347a03 100644 --- a/control-plane/internal/cli/harness_doctor_test.go +++ b/control-plane/internal/cli/harness_doctor_test.go @@ -27,6 +27,15 @@ func TestAforgeCommandHelp(t *testing.T) { require.Contains(t, stdout.String(), "--force") } +func TestAforgeEnsureCommand(t *testing.T) { + t.Setenv("AGENTFIELD_SKIP_AFORGE", "1") + for _, args := range [][]string{{"ensure"}, {"ensure", "--force"}} { + cmd := NewAforgeCommand() + cmd.SetArgs(args) + require.NoError(t, cmd.Execute()) + } +} + func TestHarnessDoctorJSONReportsRequestedProvider(t *testing.T) { binDir := t.TempDir() writeHarnessTestBinary(t, binDir, "codex", "codex-cli 1.2.3") From 8c6f7843200ada76efa387ed949494276385936d Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 17 Aug 2026 13:10:01 -0400 Subject: [PATCH 8/8] chore(aforge): pin the installer to aforge v0.1.0 and require a real version from the doctor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- control-plane/.env.example | 2 +- control-plane/internal/aforge/ensure.go | 2 +- control-plane/internal/cli/harness_doctor.go | 22 +++----- .../internal/cli/harness_doctor_test.go | 53 ++++++++++++++----- .../docker/Dockerfile.control-plane-cloud | 2 +- deployments/docker/Dockerfile.go-agent | 2 +- deployments/docker/Dockerfile.python-agent | 2 +- 7 files changed, 51 insertions(+), 34 deletions(-) diff --git a/control-plane/.env.example b/control-plane/.env.example index a454d7b90..b770c2ff5 100644 --- a/control-plane/.env.example +++ b/control-plane/.env.example @@ -83,7 +83,7 @@ AGENTFIELD_STORAGE_MODE=local # the desktop app and the agent images into $AGENTFIELD_HOME/bin/aforge). # Whole-base override for the download host — the pinned version is NOT appended, # so point it at a directory that already contains the assets + checksums.txt. -# AGENTFIELD_AFORGE_BASE_URL=https://agentfield.ai/downloads/aforge/build-9b3ff482de3f +# AGENTFIELD_AFORGE_BASE_URL=https://agentfield.ai/downloads/aforge/v0.1.0 # Set to 1 to make every aforge provisioning step a no-op (air-gapped hosts, or # images that vendor their own harness). # AGENTFIELD_SKIP_AFORGE=1 diff --git a/control-plane/internal/aforge/ensure.go b/control-plane/internal/aforge/ensure.go index 3caff9275..e74b2ac04 100644 --- a/control-plane/internal/aforge/ensure.go +++ b/control-plane/internal/aforge/ensure.go @@ -20,7 +20,7 @@ import ( const ( // Version is deliberately pinned. Bump it only when af should distribute a // newer, reviewed aforge build. - Version = "build-9b3ff482de3f" + Version = "v0.1.0" defaultBaseURL = "https://agentfield.ai/downloads/aforge/" + Version baseURLEnv = "AGENTFIELD_AFORGE_BASE_URL" skipEnv = "AGENTFIELD_SKIP_AFORGE" diff --git a/control-plane/internal/cli/harness_doctor.go b/control-plane/internal/cli/harness_doctor.go index f8293207b..5ba08ee4d 100644 --- a/control-plane/internal/cli/harness_doctor.go +++ b/control-plane/internal/cli/harness_doctor.go @@ -35,14 +35,10 @@ type harnessProviderSpec struct { // VersionArgs are tried in order until one produces output; empty means // {"--version"}. VersionArgs [][]string - // VersionOptional marks a provider that is usable without a working version - // probe. aforge's pinned build predates its `version` subcommand, so a - // present-and-executable binary is enough — the next release adds it. - VersionOptional bool } var harnessProviderSpecs = []harnessProviderSpec{ - {Name: "aforge", Binary: "aforge", InstallCommand: "af aforge ensure", AuthEnvVars: []string{"OPENROUTER_API_KEY"}, VersionArgs: [][]string{{"version"}, {"--version"}}, VersionOptional: true}, + {Name: "aforge", Binary: "aforge", InstallCommand: "af aforge ensure", AuthEnvVars: []string{"OPENROUTER_API_KEY"}, VersionArgs: [][]string{{"version"}, {"--version"}}}, // claude-code has no Binary: the Python provider runs on the // claude_agent_sdk pip package (which bundles its own CLI), not on a // globally installed `claude` binary. See claudeCodeHealth. @@ -125,13 +121,7 @@ func buildHarnessDoctorReports(requested []string) ([]HarnessProviderHealth, err if !tool.Available { issues = append(issues, "binary_not_found") } else if tool.Version == "" { - if spec.VersionOptional { - tool.Version = "unknown" - usable = true - issues = append(issues, "version_unavailable") - } else { - issues = append(issues, "version_probe_failed") - } + issues = append(issues, "version_probe_failed") } reports = append(reports, HarnessProviderHealth{ Provider: spec.Name, @@ -186,10 +176,10 @@ func probeHarnessBinary(spec harnessProviderSpec) ToolStatus { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) out, runErr := exec.CommandContext(ctx, path, args...).CombinedOutput() cancel() - // A non-zero exit is not a version, however much it printed: the pinned - // aforge build answers both `version` and `--version` with its whole - // usage text on exit 1, and accepting that output would file the usage - // banner as the installed version. + // A non-zero exit is not a version, however much it printed: CLIs + // answer an unrecognised version flag with their whole usage text on a + // non-zero exit, and accepting that output would file the usage banner + // as the installed version. if runErr != nil { continue } diff --git a/control-plane/internal/cli/harness_doctor_test.go b/control-plane/internal/cli/harness_doctor_test.go index 586347a03..f614c0277 100644 --- a/control-plane/internal/cli/harness_doctor_test.go +++ b/control-plane/internal/cli/harness_doctor_test.go @@ -164,22 +164,14 @@ func TestProbeHarnessBinaryVersionBehavior(t *testing.T) { // from CI. t.Setenv("AGENTFIELD_HOME", t.TempDir()) - // The pinned aforge build prints its whole usage banner and exits 1 for - // every version-ish argument, so the fixture does exactly that — a probe + // A CLI that does not recognise the version argument prints its usage + // banner and exits non-zero, so the fixture does exactly that — a probe // that only checked for output would file the banner as the version. - const usageOnExitOne = "echo 'aforge — build and revise task graphs' >&2; exit 1" + const usageOnExitOne = "echo 'usage: build and revise task graphs' >&2; exit 1" - t.Run("optional version", func(t *testing.T) { - writeHarnessTestScript(t, binDir, "optional", usageOnExitOne) - reports := reportsForTestSpec(t, harnessProviderSpec{Name: "optional", Binary: "optional", VersionArgs: [][]string{{"version"}, {"--version"}}, VersionOptional: true}) - require.True(t, reports[0].Usable) - require.Equal(t, "unknown", reports[0].Version) - require.Equal(t, []string{"version_unavailable"}, reports[0].Issues) - }) - - t.Run("required version", func(t *testing.T) { + t.Run("version is required", func(t *testing.T) { writeHarnessTestScript(t, binDir, "required", usageOnExitOne) - reports := reportsForTestSpec(t, harnessProviderSpec{Name: "required", Binary: "required"}) + reports := reportsForTestSpec(t, harnessProviderSpec{Name: "required", Binary: "required", VersionArgs: [][]string{{"version"}, {"--version"}}}) require.False(t, reports[0].Usable) require.Empty(t, reports[0].Version) require.Equal(t, []string{"version_probe_failed"}, reports[0].Issues) @@ -216,6 +208,41 @@ func TestHarnessDoctorAforgeSpec(t *testing.T) { require.Equal(t, []string{"OPENROUTER_API_KEY"}, reports[0].AuthEnvVars) } +// The pinned aforge release answers `version`, so aforge is held to the same +// bar as every other provider: a binary that cannot name itself is unusable. +func TestHarnessDoctorAforgeRequiresRealVersion(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture is Unix-only") + } + + t.Run("reports the version it prints", func(t *testing.T) { + binDir := t.TempDir() + writeHarnessTestScript(t, binDir, "aforge", "[ \"$1\" = version ] && printf 'aforge v0.1.0\\n'") + t.Setenv("PATH", binDir) + t.Setenv("AGENTFIELD_HOME", t.TempDir()) + + reports, err := buildHarnessDoctorReports([]string{"aforge"}) + require.NoError(t, err) + require.True(t, reports[0].Usable) + require.Equal(t, "aforge v0.1.0", reports[0].Version) + require.Empty(t, reports[0].Issues) + }) + + t.Run("unusable without a version", func(t *testing.T) { + binDir := t.TempDir() + writeHarnessTestScript(t, binDir, "aforge", "echo 'usage: aforge ' >&2; exit 1") + t.Setenv("PATH", binDir) + t.Setenv("AGENTFIELD_HOME", t.TempDir()) + + reports, err := buildHarnessDoctorReports([]string{"aforge"}) + require.NoError(t, err) + require.True(t, reports[0].Installed) + require.False(t, reports[0].Usable) + require.Empty(t, reports[0].Version) + require.Equal(t, []string{"version_probe_failed"}, reports[0].Issues) + }) +} + func reportsForTestSpec(t *testing.T, spec harnessProviderSpec) []HarnessProviderHealth { t.Helper() original := harnessProviderSpecs diff --git a/deployments/docker/Dockerfile.control-plane-cloud b/deployments/docker/Dockerfile.control-plane-cloud index 89406ea2b..a0743e8e9 100644 --- a/deployments/docker/Dockerfile.control-plane-cloud +++ b/deployments/docker/Dockerfile.control-plane-cloud @@ -66,7 +66,7 @@ RUN cd control-plane && \ FROM debian:bookworm-slim AS aforge-dist ARG TARGETARCH ARG AFORGE_BASE_URL=https://agentfield.ai/downloads/aforge -ARG AFORGE_VERSION=build-9b3ff482de3f +ARG AFORGE_VERSION=v0.1.0 RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ diff --git a/deployments/docker/Dockerfile.go-agent b/deployments/docker/Dockerfile.go-agent index 0d74979ef..53f3e1b9d 100644 --- a/deployments/docker/Dockerfile.go-agent +++ b/deployments/docker/Dockerfile.go-agent @@ -13,7 +13,7 @@ FROM debian:bookworm-slim AS aforge-dist ARG TARGETARCH ARG AFORGE_BASE_URL=https://agentfield.ai/downloads/aforge -ARG AFORGE_VERSION=build-9b3ff482de3f +ARG AFORGE_VERSION=v0.1.0 RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ diff --git a/deployments/docker/Dockerfile.python-agent b/deployments/docker/Dockerfile.python-agent index 430f14a83..18afe2b4f 100644 --- a/deployments/docker/Dockerfile.python-agent +++ b/deployments/docker/Dockerfile.python-agent @@ -13,7 +13,7 @@ FROM debian:bookworm-slim AS aforge-dist ARG TARGETARCH ARG AFORGE_BASE_URL=https://agentfield.ai/downloads/aforge -ARG AFORGE_VERSION=build-9b3ff482de3f +ARG AFORGE_VERSION=v0.1.0 RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ curl \