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..b770c2ff5 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/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 + # Development/Debug # GIN_MODE=debug # LOG_LEVEL=info diff --git a/control-plane/internal/aforge/ensure.go b/control-plane/internal/aforge/ensure.go new file mode 100644 index 000000000..e74b2ac04 --- /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 = "v0.1.0" + 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..f18182a8a --- /dev/null +++ b/control-plane/internal/aforge/ensure_test.go @@ -0,0 +1,412 @@ +package aforge + +import ( + "bytes" + "compress/gzip" + "crypto/sha256" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "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 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)) + 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 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) + 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 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() + 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/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/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 +} 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..5ba08ee4d 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,13 @@ type harnessProviderSpec struct { Binary string InstallCommand string AuthEnvVars []string + // VersionArgs are tried in order until one produces output; empty means + // {"--version"}. + VersionArgs [][]string } var harnessProviderSpecs = []harnessProviderSpec{ + {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. @@ -82,7 +88,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,8 +115,9 @@ 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 == "" { @@ -122,7 +129,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 +138,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: 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 + } + 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..f614c0277 100644 --- a/control-plane/internal/cli/harness_doctor_test.go +++ b/control-plane/internal/cli/harness_doctor_test.go @@ -11,6 +11,31 @@ 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 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") @@ -128,6 +153,111 @@ 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()) + + // 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 'usage: build and revise task graphs' >&2; exit 1" + + t.Run("version is required", func(t *testing.T) { + writeHarnessTestScript(t, binDir, "required", usageOnExitOne) + 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) + }) + + 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) +} + +// 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 + 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 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/deployments/docker/Dockerfile.control-plane-cloud b/deployments/docker/Dockerfile.control-plane-cloud index 5b059535a..a0743e8e9 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=v0.1.0 +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..53f3e1b9d 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=v0.1.0 +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..18afe2b4f 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=v0.1.0 +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.')"] 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. 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/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" 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"),