diff --git a/control-plane/internal/cli/furrow.go b/control-plane/internal/cli/furrow.go new file mode 100644 index 000000000..318f15720 --- /dev/null +++ b/control-plane/internal/cli/furrow.go @@ -0,0 +1,25 @@ +package cli + +import ( + "github.com/Agent-Field/agentfield/control-plane/internal/furrow" + "github.com/spf13/cobra" +) + +func NewFurrowCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "furrow", + Short: "Manage the furrow workspace client", + } + cmd.AddCommand(&cobra.Command{ + Use: "ensure", + Short: "Install or repair the pinned furrow workspace client", + Args: cobra.NoArgs, + // Ensure, not EnsureBestEffort: silence is the right default when an + // install merely offers to provision furrow, but someone who asks for + // it by name is owed the failure. + RunE: func(_ *cobra.Command, _ []string) error { + return furrow.Ensure(furrow.Options{}) + }, + }) + return cmd +} diff --git a/control-plane/internal/cli/furrow_test.go b/control-plane/internal/cli/furrow_test.go new file mode 100644 index 000000000..825a3153c --- /dev/null +++ b/control-plane/internal/cli/furrow_test.go @@ -0,0 +1,34 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFurrowEnsureCommand(t *testing.T) { + t.Setenv("AGENTFIELD_SKIP_FURROW", "1") + cmd := NewFurrowCommand() + cmd.SetArgs([]string{"ensure"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } +} + +func TestFurrowEnsureCommandSurfacesFailure(t *testing.T) { + home := t.TempDir() + if err := os.WriteFile(filepath.Join(home, "bin"), []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("AGENTFIELD_SKIP_FURROW", "") + t.Setenv("AGENTFIELD_HOME", home) + t.Setenv("HOME", home) + + cmd := NewFurrowCommand() + cmd.SetArgs([]string{"ensure"}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "create furrow bin directory") { + t.Fatalf("error = %v", err) + } +} diff --git a/control-plane/internal/cli/root.go b/control-plane/internal/cli/root.go index a869e3271..0e2f5a9af 100644 --- a/control-plane/internal/cli/root.go +++ b/control-plane/internal/cli/root.go @@ -108,6 +108,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()) // Create service container for framework commands cfg := &config.Config{} // Use default config for now diff --git a/control-plane/internal/furrow/ensure.go b/control-plane/internal/furrow/ensure.go new file mode 100644 index 000000000..6ef914c67 --- /dev/null +++ b/control-plane/internal/furrow/ensure.go @@ -0,0 +1,215 @@ +// Package furrow provisions the pinned furrow workspace client used by the +// agentfield-use skill. +package furrow + +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +const ( + // Version is deliberately pinned. Change it only when af should distribute + // a newer, reviewed furrow release. + Version = "v0.1.0" + defaultBaseURL = "https://github.com/Agent-Field/furrow/releases/download/" + Version + baseURLEnv = "AGENTFIELD_FURROW_BASE_URL" + skipEnv = "AGENTFIELD_SKIP_FURROW" + + // The installed version is recorded beside the binary so that bumping + // Version actually upgrades an existing install. Keying the skip on the + // file's existence alone would pin every machine to whatever it first got. + versionMarker = ".furrow.version" + + // Generous next to a ~8MB binary, but bounded: an unexpected endpoint + // should not be able to stream until the process runs out of memory. + maxDownloadBytes = 64 << 20 +) + +type Options struct { + GOOS, GOARCH string + Home string + BaseURL string + Client *http.Client +} + +func AssetName(goos, goarch string) (string, bool) { + switch goos + "/" + goarch { + case "linux/amd64": + return "furrow-linux-amd64", true + case "darwin/arm64": + return "furrow-darwin-arm64", true + case "darwin/amd64": + return "furrow-darwin-amd64", true + default: + return "", false + } +} + +// Ensure installs furrow 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", "furrow") + markerPath := filepath.Join(home, "bin", versionMarker) + binDir := filepath.Dir(destination) + if err := os.MkdirAll(binDir, 0o755); err != nil { + return fmt.Errorf("create furrow bin directory: %w", err) + } + unlock, err := lockFurrow(filepath.Join(binDir, ".furrow.lock")) + if err != nil { + return fmt.Errorf("lock furrow installation: %w", err) + } + defer func() { _ = unlock() }() + if 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, + }, + Timeout: 3 * time.Minute, + } + } + + checksums, err := download(client, baseURL+"/SHA256SUMS") + if err != nil { + return fmt.Errorf("download checksums: %w", err) + } + want, err := checksumFor(checksums, asset) + if err != nil { + return err + } + binary, err := download(client, baseURL+"/"+asset) + 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, ".furrow-*") + if err != nil { + return fmt.Errorf("create temporary furrow 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 furrow file: %w", err) + } + if err := os.Rename(tmpName, destination); err != nil { + return fmt.Errorf("install furrow: %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 furrow version: %w", err) + } + return nil +} + +func alreadyInstalled(destination, markerPath string) bool { + info, err := os.Stat(destination) + if err != nil || !info.Mode().IsRegular() || 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: furrow 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, maxDownloadBytes)) +} + +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/furrow/ensure_test.go b/control-plane/internal/furrow/ensure_test.go new file mode 100644 index 000000000..e8bf73e08 --- /dev/null +++ b/control-plane/internal/furrow/ensure_test.go @@ -0,0 +1,429 @@ +package furrow + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" +) + +func TestAssetName(t *testing.T) { + tests := []struct { + goos, goarch, want string + ok bool + }{ + {"linux", "amd64", "furrow-linux-amd64", true}, + {"darwin", "arm64", "furrow-darwin-arm64", true}, + {"darwin", "amd64", "furrow-darwin-amd64", true}, + {"windows", "amd64", "", false}, + {"linux", "arm64", "", 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) + } + }) + } +} + +func TestEnsureWindowsIsNoOp(t *testing.T) { + home := t.TempDir() + if err := Ensure(Options{GOOS: "windows", GOARCH: "amd64", Home: home, BaseURL: "http://must-not-be-used.invalid"}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(home, "bin", "furrow")); !os.IsNotExist(err) { + t.Fatalf("furrow unexpectedly exists: %v", err) + } +} + +func TestEnsureDefaultsRuntimePlatform(t *testing.T) { + asset, ok := AssetName(runtime.GOOS, runtime.GOARCH) + if !ok { + t.Skipf("furrow is not supported on %s/%s", runtime.GOOS, runtime.GOARCH) + } + home := t.TempDir() + payload := []byte("runtime furrow") + sum := sha256.Sum256(payload) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/SHA256SUMS": + _, _ = fmt.Fprintf(w, "%x %s\n", sum, asset) + case "/" + asset: + _, _ = w.Write(payload) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + if err := Ensure(Options{Home: home, BaseURL: server.URL}); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(home, "bin", "furrow")) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, payload) { + t.Fatalf("content = %q, want %q", got, payload) + } +} + +func TestEnsureFailsWithoutResolvableHome(t *testing.T) { + if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + t.Skip("empty HOME behavior is specific to Unix") + } + t.Setenv("AGENTFIELD_HOME", "") + t.Setenv("HOME", "") + err := Ensure(Options{GOOS: "linux", GOARCH: "amd64"}) + if err == nil || !strings.Contains(err.Error(), "home") { + t.Fatalf("error = %v, want home resolution failure", err) + } +} + +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 furrow bin directory") { + t.Fatalf("error = %v", err) + } +} + +func TestEnsureSkipsExecutableWithoutDownload(t *testing.T) { + home := t.TempDir() + path := filepath.Join(home, "bin", "furrow") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("existing"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, "bin", versionMarker), []byte(Version+"\n"), 0o644); err != nil { + t.Fatal(err) + } + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests.Add(1) })) + defer 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("got %d requests, want 0", requests.Load()) + } +} + +// Bumping Version has to actually reach machines that already have furrow; +// skipping on the binary's existence alone would pin them forever. +func TestEnsureUpgradesWhenInstalledVersionDiffers(t *testing.T) { + home := t.TempDir() + path := filepath.Join(home, "bin", "furrow") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("stale binary"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, "bin", versionMarker), []byte("v0.0.1\n"), 0o644); err != nil { + t.Fatal(err) + } + + payload := []byte("fresh binary") + sum := sha256.Sum256(payload) + server := releaseServer(t, payload, hex.EncodeToString(sum[:])) + if err := Ensure(Options{GOOS: "linux", GOARCH: "amd64", Home: home, BaseURL: server.URL}); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(payload) { + t.Fatalf("binary = %q, want the freshly downloaded one", got) + } + marker, err := os.ReadFile(filepath.Join(home, "bin", versionMarker)) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(marker)) != Version { + t.Fatalf("marker = %q, want %q", strings.TrimSpace(string(marker)), Version) + } +} + +// A successful install must leave the marker behind, or every later Ensure +// re-downloads a binary it already has. +func TestEnsureRecordsVersionSoTheNextRunSkips(t *testing.T) { + home := t.TempDir() + payload := []byte("furrow") + sum := sha256.Sum256(payload) + var requests atomic.Int32 + server := countingReleaseServer(t, payload, hex.EncodeToString(sum[:]), &requests) + + for i := 0; i < 2; i++ { + if err := Ensure(Options{GOOS: "linux", GOARCH: "amd64", Home: home, BaseURL: server.URL}); err != nil { + t.Fatalf("Ensure() run %d: %v", i+1, err) + } + } + if requests.Load() == 0 { + t.Fatal("first Ensure() made no requests; the test server was not exercised") + } + before := requests.Load() + if err := Ensure(Options{GOOS: "linux", GOARCH: "amd64", Home: home, BaseURL: server.URL}); err != nil { + t.Fatal(err) + } + if requests.Load() != before { + t.Fatalf("a third Ensure() made %d extra requests, want 0", requests.Load()-before) + } +} + +func TestEnsureConcurrentDownloadsAssetOnce(t *testing.T) { + home := t.TempDir() + payload := []byte("concurrently installed furrow") + sum := sha256.Sum256(payload) + var assetRequests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/SHA256SUMS": + _, _ = fmt.Fprintf(w, "%x furrow-linux-amd64\n", sum) + case "/furrow-linux-amd64": + assetRequests.Add(1) + _, _ = w.Write(payload) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + const ensures = 12 + errors := make(chan error, ensures) + var group sync.WaitGroup + for range ensures { + group.Add(1) + go func() { + defer group.Done() + errors <- Ensure(Options{GOOS: "linux", GOARCH: "amd64", Home: home, BaseURL: server.URL}) + }() + } + group.Wait() + close(errors) + for err := range errors { + if err != nil { + t.Fatalf("Ensure() = %v", err) + } + } + + binary, err := os.ReadFile(filepath.Join(home, "bin", "furrow")) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(binary, payload) { + t.Fatalf("binary = %q, want %q", binary, payload) + } + marker, err := os.ReadFile(filepath.Join(home, "bin", versionMarker)) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(marker)) != Version { + t.Fatalf("marker = %q, want %q", strings.TrimSpace(string(marker)), Version) + } + if assetRequests.Load() != 1 { + t.Fatalf("asset downloads = %d, want 1", assetRequests.Load()) + } +} + +func TestEnsureRejectsChecksumMismatchAndWritesNothing(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(), "checksum mismatch") { + t.Fatalf("error = %v", err) + } + assertNoFurrowArtifacts(t, home) +} + +func TestEnsureDownloadFailureIsNonFatal(t *testing.T) { + home := t.TempDir() + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + var warnings bytes.Buffer + if err := EnsureBestEffort(Options{GOOS: "linux", GOARCH: "amd64", Home: home, BaseURL: server.URL}, &warnings); err != nil { + t.Fatalf("EnsureBestEffort returned %v", err) + } + if strings.Count(strings.TrimSpace(warnings.String()), "\n") != 0 { + t.Fatalf("warning was not one line: %q", warnings.String()) + } + assertNoFurrowArtifacts(t, home) +} + +func TestEnsureBestEffortAcceptsNilWarnings(t *testing.T) { + home := t.TempDir() + server := httptest.NewServer(http.NotFoundHandler()) + t.Cleanup(server.Close) + if err := EnsureBestEffort(Options{GOOS: "linux", GOARCH: "amd64", Home: home, BaseURL: server.URL}, nil); err != nil { + t.Fatalf("EnsureBestEffort returned %v", err) + } +} + +func TestEnsureRejectsInvalidChecksum(t *testing.T) { + home := t.TempDir() + server := releaseServer(t, []byte("unused"), "not-a-valid-checksum") + err := Ensure(Options{GOOS: "linux", GOARCH: "amd64", Home: home, BaseURL: server.URL}) + if err == nil || !strings.Contains(err.Error(), "invalid checksum for") { + t.Fatalf("error = %v", err) + } +} + +func TestEnsureRejectsChecksumsMissingTheAsset(t *testing.T) { + home := t.TempDir() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/SHA256SUMS" { + _, _ = fmt.Fprintf(w, "%x some-other-asset\n", sha256.Sum256([]byte("x"))) + return + } + http.NotFound(w, r) + })) + t.Cleanup(server.Close) + err := Ensure(Options{GOOS: "linux", GOARCH: "amd64", Home: home, BaseURL: server.URL}) + if err == nil || !strings.Contains(err.Error(), "checksum missing for") { + t.Fatalf("error = %v", err) + } + assertNoFurrowArtifacts(t, home) +} + +func TestEnsureBinaryDownloadFailureLeavesNoArtifacts(t *testing.T) { + home := t.TempDir() + payload := []byte("furrow binary") + sum := sha256.Sum256(payload) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/SHA256SUMS" { + _, _ = fmt.Fprintf(w, "%x furrow-linux-amd64\n", sum) + return + } + http.NotFound(w, r) + })) + t.Cleanup(server.Close) + err := Ensure(Options{GOOS: "linux", GOARCH: "amd64", Home: home, BaseURL: server.URL}) + if err == nil || !strings.Contains(err.Error(), "download furrow-linux-amd64") { + t.Fatalf("error = %v", err) + } + assertNoFurrowArtifacts(t, home) +} + +func TestEnsureAtomicRenameFailureLeavesNoPartialFile(t *testing.T) { + home := t.TempDir() + destination := filepath.Join(home, "bin", "furrow") + if err := os.MkdirAll(destination, 0o755); err != nil { + t.Fatal(err) + } + payload := []byte("valid furrow") + sum := sha256.Sum256(payload) + server := releaseServer(t, payload, fmt.Sprintf("%x", sum)) + err := Ensure(Options{GOOS: "linux", GOARCH: "amd64", Home: home, BaseURL: server.URL}) + if err == nil || !strings.Contains(err.Error(), "install furrow") { + t.Fatalf("error = %v", err) + } + entries, readErr := os.ReadDir(filepath.Join(home, "bin")) + if readErr != nil { + t.Fatal(readErr) + } + if len(entries) != 2 || entries[0].Name() != ".furrow.lock" || entries[1].Name() != "furrow" || !entries[1].IsDir() { + t.Fatalf("partial artifacts left behind: %+v", entries) + } +} + +func TestEnsureInstallsExecutable(t *testing.T) { + home := t.TempDir() + payload := []byte("valid furrow") + sum := sha256.Sum256(payload) + server := releaseServer(t, payload, fmt.Sprintf("%x", sum)) + t.Setenv("AGENTFIELD_FURROW_BASE_URL", server.URL) + if err := Ensure(Options{GOOS: "linux", GOARCH: "amd64", Home: home}); err != nil { + t.Fatal(err) + } + path := filepath.Join(home, "bin", "furrow") + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, payload) { + t.Fatalf("content = %q", got) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o755 { + t.Fatalf("mode = %o", info.Mode().Perm()) + } +} + +func TestEnsureHonorsOptOut(t *testing.T) { + home := t.TempDir() + t.Setenv("AGENTFIELD_SKIP_FURROW", "1") + if err := Ensure(Options{GOOS: "linux", GOARCH: "amd64", Home: home, BaseURL: "http://must-not-be-used.invalid"}); err != nil { + t.Fatal(err) + } + assertNoFurrowArtifacts(t, home) +} + +func releaseServer(t *testing.T, payload []byte, checksum string) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/SHA256SUMS": + _, _ = fmt.Fprintf(w, "%s furrow-linux-amd64\n", checksum) + case "/furrow-linux-amd64": + _, _ = w.Write(payload) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + return server +} + +// countingReleaseServer is releaseServer with a request tally, for asserting +// that a second Ensure does no network work. +func countingReleaseServer(t *testing.T, payload []byte, checksum string, requests *atomic.Int32) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + switch r.URL.Path { + case "/SHA256SUMS": + _, _ = fmt.Fprintf(w, "%s furrow-linux-amd64\n", checksum) + case "/furrow-linux-amd64": + _, _ = w.Write(payload) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + return server +} + +func assertNoFurrowArtifacts(t *testing.T, home string) { + t.Helper() + entries, err := os.ReadDir(filepath.Join(home, "bin")) + if os.IsNotExist(err) { + return + } + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Name() != ".furrow.lock" { + t.Fatalf("unexpected artifacts: %+v", entries) + } +} diff --git a/control-plane/internal/furrow/ensure_unix_test.go b/control-plane/internal/furrow/ensure_unix_test.go new file mode 100644 index 000000000..bd7518c72 --- /dev/null +++ b/control-plane/internal/furrow/ensure_unix_test.go @@ -0,0 +1,31 @@ +//go:build unix + +package furrow + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +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 furrow installation") { + t.Fatalf("error = %v", err) + } +} diff --git a/control-plane/internal/furrow/lock_other.go b/control-plane/internal/furrow/lock_other.go new file mode 100644 index 000000000..0500a1b81 --- /dev/null +++ b/control-plane/internal/furrow/lock_other.go @@ -0,0 +1,7 @@ +//go:build !unix + +package furrow + +func lockFurrow(string) (func() error, error) { + return func() error { return nil }, nil +} diff --git a/control-plane/internal/furrow/lock_unix.go b/control-plane/internal/furrow/lock_unix.go new file mode 100644 index 000000000..db13937b0 --- /dev/null +++ b/control-plane/internal/furrow/lock_unix.go @@ -0,0 +1,20 @@ +//go:build unix + +package furrow + +import ( + "os" + "syscall" +) + +func lockFurrow(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/server/routes_core.go b/control-plane/internal/server/routes_core.go index 23f0f134d..bcaab9347 100644 --- a/control-plane/internal/server/routes_core.go +++ b/control-plane/internal/server/routes_core.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "os" "time" "github.com/Agent-Field/agentfield/control-plane/internal/config" @@ -216,6 +217,9 @@ func (s *AgentFieldServer) healthCheckHandler(c *gin.Context) { "version": "1.0.0", // TODO: Get from build info "checks": gin.H{}, } + if furrowPublicAddr := os.Getenv("FURROW_PUBLIC_ADDR"); furrowPublicAddr != "" { + healthStatus["furrow_public_addr"] = furrowPublicAddr + } allHealthy := true checks := healthStatus["checks"].(gin.H) diff --git a/control-plane/internal/server/server_test.go b/control-plane/internal/server/server_test.go index d61353b17..24b61c4ee 100644 --- a/control-plane/internal/server/server_test.go +++ b/control-plane/internal/server/server_test.go @@ -165,6 +165,47 @@ func TestHealthCheckHandlerHealthy(t *testing.T) { } } +func TestHealthCheckHandlerFurrowPublicAddr(t *testing.T) { + tests := []struct { + name string + address string + wantValue bool + }{ + {name: "set", address: "furrow.example.com:7443", wantValue: true}, + {name: "empty", address: "", wantValue: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("FURROW_PUBLIC_ADDR", tt.address) + gin.SetMode(gin.TestMode) + srv := &AgentFieldServer{ + storageHealthOverride: func(context.Context) gin.H { return gin.H{"status": "healthy"} }, + cacheHealthOverride: func(context.Context) gin.H { return gin.H{"status": "healthy"} }, + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + c.Request = req + + srv.healthCheckHandler(c) + + var payload map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + got, present := payload["furrow_public_addr"] + if present != tt.wantValue { + t.Fatalf("furrow_public_addr presence = %v, want %v; payload: %+v", present, tt.wantValue, payload) + } + if tt.wantValue && got != tt.address { + t.Fatalf("furrow_public_addr = %v, want %q", got, tt.address) + } + }) + } +} + func TestHealthCheckHandlerCacheOptional(t *testing.T) { gin.SetMode(gin.TestMode) srv := &AgentFieldServer{ diff --git a/control-plane/internal/skillkit/catalog.go b/control-plane/internal/skillkit/catalog.go index b75f7d702..c962897df 100644 --- a/control-plane/internal/skillkit/catalog.go +++ b/control-plane/internal/skillkit/catalog.go @@ -52,7 +52,7 @@ read this skill first`, }, { Name: "agentfield-use", - Version: "0.4.0", + Version: "0.5.0", Description: "Discover and call agents already running on a local AgentField control plane. Zero-setup MCP endpoint at /mcp, health check, capability discovery, ranked reasoner search (af agent search), concurrent sync/async execution, load-aware pacing (meta.load), in-flight visibility (af ps / executions/active), wedged-run triage (cancel-tree), sessions, and the af CLI ops (run/stop/logs/secrets) that keep installed agents answering.", EmbedRoot: "skill_data/agentfield-use", EntryFile: "SKILL.md", diff --git a/control-plane/internal/skillkit/catalog_agentfield_use_test.go b/control-plane/internal/skillkit/catalog_agentfield_use_test.go index 395e56f84..dd2a584dc 100644 --- a/control-plane/internal/skillkit/catalog_agentfield_use_test.go +++ b/control-plane/internal/skillkit/catalog_agentfield_use_test.go @@ -89,8 +89,8 @@ func TestAgentfieldUseSourceFallbackContract(t *testing.T) { if err != nil { t.Fatalf("parse source frontmatter: %v", err) } - if frontmatter.Name != "agentfield-use" || frontmatter.Version != "0.4.0" { - t.Fatalf("source frontmatter = %+v, want name=agentfield-use version=0.4.0", frontmatter) + if frontmatter.Name != "agentfield-use" || frontmatter.Version != "0.5.0" { + t.Fatalf("source frontmatter = %+v, want name=agentfield-use version=0.5.0", frontmatter) } // The offer is available only after coverage is conclusively checked, it diff --git a/control-plane/internal/skillkit/furrow_install_test.go b/control-plane/internal/skillkit/furrow_install_test.go new file mode 100644 index 000000000..019479627 --- /dev/null +++ b/control-plane/internal/skillkit/furrow_install_test.go @@ -0,0 +1,46 @@ +package skillkit + +import ( + "crypto/sha256" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestInstallAgentfieldUseEnsuresFurrow(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("AGENTFIELD_HOME", home) + t.Setenv("CODEX_HOME", filepath.Join(home, "codex")) + + payload := []byte("furrow from skill install") + sum := sha256.Sum256(payload) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/SHA256SUMS": + for _, asset := range []string{"furrow-linux-amd64", "furrow-darwin-arm64", "furrow-darwin-amd64"} { + _, _ = fmt.Fprintf(w, "%x %s\n", sum, asset) + } + case "/furrow-linux-amd64", "/furrow-darwin-arm64", "/furrow-darwin-amd64": + _, _ = w.Write(payload) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + t.Setenv("AGENTFIELD_FURROW_BASE_URL", server.URL) + + if _, err := Install(InstallOptions{SkillName: "agentfield-use", Targets: []string{"codex"}}); err != nil { + t.Fatalf("Install(agentfield-use): %v", err) + } + info, err := os.Stat(filepath.Join(home, "bin", "furrow")) + if err != nil { + t.Fatalf("stat provisioned furrow: %v", err) + } + if info.Mode().Perm()&0o111 == 0 { + t.Fatalf("provisioned furrow mode = %o, want executable", info.Mode().Perm()) + } +} diff --git a/control-plane/internal/skillkit/install.go b/control-plane/internal/skillkit/install.go index 281398b62..fc6463b3f 100644 --- a/control-plane/internal/skillkit/install.go +++ b/control-plane/internal/skillkit/install.go @@ -6,6 +6,8 @@ import ( "path/filepath" "sort" "time" + + "github.com/Agent-Field/agentfield/control-plane/internal/furrow" ) // InstallOptions controls how a skill is installed across targets. @@ -159,6 +161,9 @@ func install(opts InstallOptions, reconcile bool) (*InstallReport, error) { if err := SaveState(state); err != nil { return nil, fmt.Errorf("save state: %w", err) } + if skill.Name == "agentfield-use" { + _ = furrow.EnsureBestEffort(furrow.Options{}, os.Stderr) + } } return report, nil diff --git a/control-plane/internal/skillkit/install_all_test.go b/control-plane/internal/skillkit/install_all_test.go index 21f53c0b3..f88176688 100644 --- a/control-plane/internal/skillkit/install_all_test.go +++ b/control-plane/internal/skillkit/install_all_test.go @@ -8,6 +8,7 @@ import "testing" // catalog entry. Uses the codex marker-block target so no symlink-only path is // required and everything lands under an isolated temp HOME. func TestInstallAllInstallsEveryCatalogSkill(t *testing.T) { + t.Setenv("AGENTFIELD_SKIP_FURROW", "1") home := t.TempDir() t.Setenv("HOME", home) t.Setenv("AGENTFIELD_HOME", home) diff --git a/control-plane/internal/skillkit/reconcile_test.go b/control-plane/internal/skillkit/reconcile_test.go index c51547860..d8e614e93 100644 --- a/control-plane/internal/skillkit/reconcile_test.go +++ b/control-plane/internal/skillkit/reconcile_test.go @@ -593,6 +593,7 @@ func TestReconcileAliasOrphansSaveFailureLeavesSerializedState(t *testing.T) { } func TestPublicOperationsReconcileAndDryRunsDoNot(t *testing.T) { + t.Setenv("AGENTFIELD_SKIP_FURROW", "1") for _, op := range []struct { name string run func() error diff --git a/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md b/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md index 1e54edae2..bcdc8202c 100644 --- a/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md +++ b/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md @@ -1,6 +1,6 @@ --- name: agentfield-use -version: 0.4.0 +version: 0.5.0 description: "Discover and call agents already running on a local AgentField control plane. Use when the user asks to use, call, query, run, or delegate work to an installed AgentField agent (swe-planner, pr-af, sec-af, …), to list what agents or reasoners are available, or to check on an execution. Not for building new agents — that is the agentfield skill." --- @@ -210,6 +210,65 @@ Long-running agents can take tens of minutes — poll with backoff (start ~5s, settle at ~30s) and tell the user what is in flight. For live progress, stream Server-Sent Events from `GET /api/v1/executions//events`. +### If the result carries a `workspace_handle`, you can read the files + +Some agents (SWE-AF) mirror the workspace they are building in, so you can open +the actual files instead of reasoning from the summary — including uncommitted +edits and untracked files that no git push would carry. You do not ask whether +this is available and there is nothing to configure: the handle is in the result +when it works and absent when it doesn't. + +```json +"workspace_handle": {"v":1, "remote":"ssh://host:port"|"dir:/path", + "namespace":"...", "key":"<64 hex>", "token":"..."} +``` + +`furrow` is rarely on PATH. AgentField installs it to `$AGENTFIELD_HOME/bin/` +(default `~/.agentfield/bin/`), and a node that ships its own copy keeps it +inside the installed package. Resolve it from those; do not try to install it +yourself. POSIX sh only — no brace expansion, so the package dirs are spelled +out. + +```sh +os=$(uname -s | tr A-Z a-z) +af_home=${AGENTFIELD_HOME:-$HOME/.agentfield} +furrow_bin() { # $1 = furrow | furrow-dial + command -v "$1" 2>/dev/null && return + for c in "$af_home/bin/$1" \ + "$af_home"/packages/*/bin/"$1"-"$os"-* \ + "$af_home"/packages/*/go/bin/"$1"-"$os"-*; do + [ -x "$c" ] && { echo "$c"; return; } + done +} +FURROW=$(furrow_bin furrow) DIAL=$(furrow_bin furrow-dial) +``` + +A `dir:` handle needs only `$FURROW`; an `ssh://` handle needs `$DIAL` too. If +either is missing, say so plainly and carry on from the result — the mirror is +fine, this machine just has no client for it. + +```bash +# ssh:// handle — furrow-dial carries the protocol; nothing else changes +export FURROW_SSH_COMMAND="$DIAL" FURROW_DIAL_TOKEN= FURROW_DIAL_INSECURE=1 +FURROW_RECOVERY_KEY= "$FURROW" clone / ./run-workspace --no-watch + +# dir: handle (same machine) — clone rejects directory remotes, so pair instead. +# The path is the handle's remote with the "dir:" prefix removed; don't append +# anything to it. +git init -q run-workspace && "$FURROW" --repo run-workspace watch --no-daemon +"$FURROW" --repo run-workspace pair --name --key +"$FURROW" --repo run-workspace sync --pull --bootstrap +``` + +`"$FURROW" --repo run-workspace sync --follow` keeps it current while the run +works. Read and diff freely. Treat it as a mirror, not a shared drive: it is +one-writer, and edits go back as a merge (`furrow merge --check ""`), +so change files between issues or on a fork rather than while the agent writes. + +`get_workspace_handle` re-fetches a handle mid-run: +`POST /api/v1/execute/.get_workspace_handle` with `{"input":{"run_id":"..."}}`. +`{"available": false}` means no mirror — carry on without it. + **Several at once:** `POST /api/v1/executions/batch-status` with `{"execution_ids": [...]}`. Terminal entries embed the FULL result payload — responses can be large (100KB+), so write to a file and parse from there; never @@ -280,6 +339,9 @@ is enabled), and verify offline with `af verify audit.json`. ## Hard rules - Every call goes through the control plane — never POST to an agent's own port. + The one exception is a `workspace_handle`: its `ssh://` endpoint is a furrow + transport, not the agent's HTTP port, and the per-run token in the handle is + what authorizes it. Reading files there is not an agent call. - Kwargs live under `"input"`. Empty input is `{"input": {}}`. - Async + poll for anything that might exceed a few seconds; sync is for quick lookups only. Independent async calls go out together, not one at a time. diff --git a/control-plane/internal/skillkit/skill_mirror_test.go b/control-plane/internal/skillkit/skill_mirror_test.go index 1020258ea..d1654b844 100644 --- a/control-plane/internal/skillkit/skill_mirror_test.go +++ b/control-plane/internal/skillkit/skill_mirror_test.go @@ -21,7 +21,7 @@ func TestSkillCatalogAndEmbeddedMirrorsStayAligned(t *testing.T) { }{ {name: "agentfield", version: "0.5.2"}, {name: "agentfield-personal", version: "0.1.0"}, - {name: "agentfield-use", version: "0.4.0"}, + {name: "agentfield-use", version: "0.5.0"}, } for _, tt := range tests { diff --git a/deployments/docker/Dockerfile.control-plane-cloud b/deployments/docker/Dockerfile.control-plane-cloud index ddb69c1be..5b059535a 100644 --- a/deployments/docker/Dockerfile.control-plane-cloud +++ b/deployments/docker/Dockerfile.control-plane-cloud @@ -105,6 +105,10 @@ RUN npm install -g opencode-ai # Everything stateful (SQLite, BoltDB, installed.yaml, secrets keyring, # agent package dirs) lives under one mount point. ENV AGENTFIELD_HOME=/data +# The furrow client is a laptop-side tool: cloud agents get furrowd vendored +# by their own packages, and nothing in this container clones workspaces. Any +# skill install run in here must not pull it from GitHub onto the volume. +ENV AGENTFIELD_SKIP_FURROW=1 RUN mkdir -p /data EXPOSE 8080 diff --git a/desktop/src/main/cloud.test.ts b/desktop/src/main/cloud.test.ts index bf2ed84c1..3b6647233 100644 --- a/desktop/src/main/cloud.test.ts +++ b/desktop/src/main/cloud.test.ts @@ -61,6 +61,7 @@ describe('testCloudConnection', () => { authOk: true, installApi: true, furrowAvailable: true, + furrowReported: true, version: '1.2.3', message: 'Connection successful' }) @@ -138,6 +139,7 @@ describe('testCloudConnection', () => { furrowProbe: async () => false }) expect(result).toMatchObject({ ok: true, healthy: true, authOk: true, furrowAvailable: false }) + expect(result.furrowReported).toBe(true) }) }) diff --git a/desktop/src/main/cloud.ts b/desktop/src/main/cloud.ts index 90ccbb748..5fc612e71 100644 --- a/desktop/src/main/cloud.ts +++ b/desktop/src/main/cloud.ts @@ -234,6 +234,7 @@ export async function testCloudConnection( authOk: true, installApi, furrowAvailable, + ...(furrowAddress ? { furrowReported: true } : {}), ...(version ? { version } : {}), message: installApi ? 'Connection successful' : 'Connected; install API unavailable' } diff --git a/desktop/src/main/deployEngine.test.ts b/desktop/src/main/deployEngine.test.ts index 6cf05d959..bc2247194 100644 --- a/desktop/src/main/deployEngine.test.ts +++ b/desktop/src/main/deployEngine.test.ts @@ -5,7 +5,7 @@ import { delimiter, join } from 'node:path' import { tmpdir } from 'node:os' import { mkdtempSync } from 'node:fs' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import { generateApiKey, hasDeployment, resolveCloudImage, resolveTofuBinary, runDeploy, runDestroy } from './deployEngine' +import { checkCloudImageUpdate, generateApiKey, hasDeployment, resolveCloudImage, resolveTofuBinary, runDeploy, runDestroy } from './deployEngine' type Script = { stdout?: string; stderr?: string; code?: number } @@ -245,6 +245,55 @@ describe('deployment module and execution', () => { }) }) +describe('cloud image updates', () => { + it('compares the image recorded in state with the latest production pin', async () => { + const fixture = workspace() + mkdirSync(fixture.opts.workspaceDir, { recursive: true }) + writeFileSync(join(fixture.opts.workspaceDir, 'terraform.tfstate'), deployedState( + 'prior-key', + 'agentfield-dead', + 'agentfield/control-plane-cloud:v0.1.124' + )) + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ results: [ + { name: 'latest', digest: 'sha256:new' }, + { name: 'v0.1.125', digest: 'sha256:new' } + ] }), { status: 200 })) as typeof fetch + + await expect(checkCloudImageUpdate(fixture.opts.workspaceDir, fetchImpl)).resolves.toEqual({ + current: 'agentfield/control-plane-cloud:v0.1.124', + latest: 'agentfield/control-plane-cloud:v0.1.125', + updateAvailable: true + }) + }) + + it('does not look up a release when deployment state is absent', async () => { + const fetchImpl = vi.fn() as unknown as typeof fetch + await expect(checkCloudImageUpdate('/does/not/exist', fetchImpl)).resolves.toEqual({ + current: null, + latest: null, + updateAvailable: false + }) + expect(fetchImpl).not.toHaveBeenCalled() + }) + + it('keeps the current pin and reports no update when lookup fails', async () => { + const fixture = workspace() + mkdirSync(fixture.opts.workspaceDir, { recursive: true }) + writeFileSync(join(fixture.opts.workspaceDir, 'terraform.tfstate'), deployedState( + 'prior-key', + 'agentfield-dead', + 'agentfield/control-plane-cloud:v0.1.124' + )) + const fetchImpl = vi.fn(async () => { throw new Error('offline') }) as typeof fetch + + await expect(checkCloudImageUpdate(fixture.opts.workspaceDir, fetchImpl)).resolves.toEqual({ + current: 'agentfield/control-plane-cloud:v0.1.124', + latest: null, + updateAvailable: false + }) + }) +}) + describe('cloud image resolution', () => { const hub = (results: Array<{ name: string; digest?: string }>, status = 200) => vi.fn(async () => new Response(JSON.stringify({ results }), { status })) as typeof fetch diff --git a/desktop/src/main/deployEngine.ts b/desktop/src/main/deployEngine.ts index d68db3198..94f3a8882 100644 --- a/desktop/src/main/deployEngine.ts +++ b/desktop/src/main/deployEngine.ts @@ -27,6 +27,12 @@ export interface DeployResult { message: string } +export interface CloudImageUpdate { + current: string | null + latest: string | null + updateAvailable: boolean +} + const MODULE = `terraform { required_providers { railway = { source = "terraform-community-providers/railway", version = "0.6.2" } @@ -223,6 +229,20 @@ function stateSourceImage(state: TfState | null): string | null { return typeof value === 'string' && value.length > 0 ? value : null } +export async function checkCloudImageUpdate( + workspaceDir: string, + fetchImpl: typeof fetch = fetch +): Promise { + const current = stateSourceImage(readState(workspaceDir)) + if (!current) return { current: null, latest: null, updateAvailable: false } + const latest = await resolveCloudImage(fetchImpl) + return { + current, + latest, + updateAvailable: latest !== null && latest !== current + } +} + function writeConfig(workspaceDir: string, binaryDir?: string | null): string | null { if (!binaryDir) return null const mirror = join(binaryDir, 'providers') diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index a3d8f1e59..b0a6d4a61 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -34,7 +34,7 @@ import { logout, type RailwayAuthDeps } from './railwayAuth' -import { hasDeployment, resolveTofuBinary, runDeploy, runDestroy } from './deployEngine' +import { checkCloudImageUpdate, hasDeployment, resolveTofuBinary, runDeploy, runDestroy } from './deployEngine' import appIcon from '../../resources/icon.png?asset' const isMac = process.platform === 'darwin' @@ -329,7 +329,9 @@ function main(): void { // with no AgentField at all this provisions the bundled CLI, so a // desktop-app-only install still gets a working `af`. await initializeCli(bundledCliPath()) - if (settings.installSkills && !isCloudActive()) syncSkills() + // Skills and the furrow client belong to local coding agents even when + // their control plane and workspaces are remote. + if (settings.installSkills) syncSkills() // macOS only: provision + install the af-tray menu-bar companion so a // desktop-app-only install gets the menu-bar icon. Runs after initializeCli @@ -523,6 +525,10 @@ function main(): void { workspaces: token ? await listWorkspaces(token) : [] } }) + ipcMain.handle('agentfield:cloud-image-update', () => { + const { workspaceDir } = deployPaths() + return checkCloudImageUpdate(workspaceDir) + }) ipcMain.handle('agentfield:railway-login', async () => { const deps = authDeps() const result = await loginWithRailway(deps) diff --git a/desktop/src/preload/index.ts b/desktop/src/preload/index.ts index b2c447d32..498fd3d5c 100644 --- a/desktop/src/preload/index.ts +++ b/desktop/src/preload/index.ts @@ -27,6 +27,7 @@ const api: AgentFieldApi = { cloudTest: (url, apiKey) => ipcRenderer.invoke('agentfield:cloud-test', url, apiKey), cloudDeployRailway: () => ipcRenderer.invoke('agentfield:cloud-deploy-railway'), railwayStatus: () => ipcRenderer.invoke('agentfield:railway-status'), + checkCloudImageUpdate: () => ipcRenderer.invoke('agentfield:cloud-image-update'), railwayLogin: () => ipcRenderer.invoke('agentfield:railway-login'), railwayLogout: () => ipcRenderer.invoke('agentfield:railway-logout'), cloudDeploy: (workspaceId) => ipcRenderer.invoke('agentfield:cloud-deploy', workspaceId), diff --git a/desktop/src/renderer/src/components/CloudPanel.tsx b/desktop/src/renderer/src/components/CloudPanel.tsx index 15519834e..d0fc21915 100644 --- a/desktop/src/renderer/src/components/CloudPanel.tsx +++ b/desktop/src/renderer/src/components/CloudPanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import type { CloudDeployResult, + CloudImageUpdate, CloudTestResult, DesktopSettings, RailwayStatus, @@ -29,6 +30,8 @@ export function CloudPanel() { const [error, setError] = useState(null); const [confirmation, setConfirmation] = useState(null); const [railway, setRailway] = useState(null); + const [cloudImageUpdate, setCloudImageUpdate] = + useState(null); const [railwayBusy, setRailwayBusy] = useState< "login" | "deploy" | "destroy" | null >(null); @@ -80,6 +83,17 @@ export function CloudPanel() { if (railway && !railway.engineAvailable) setActiveTab("manual"); }, [railway?.engineAvailable]); + useEffect(() => { + if (activeTab !== "railway" || !railway?.hasDeployment) { + setCloudImageUpdate(null); + return; + } + void window.agentfield + .checkCloudImageUpdate() + .then(setCloudImageUpdate) + .catch(() => setCloudImageUpdate(null)); + }, [activeTab, railway?.hasDeployment]); + useEffect(() => { if (!confirmation) return; const timeout = window.setTimeout(() => setConfirmation(null), 4000); @@ -203,6 +217,11 @@ export function CloudPanel() { setApiKey(next.cloud.apiKey); } await refreshRailway(); + setCloudImageUpdate( + await window.agentfield + .checkCloudImageUpdate() + .catch(() => null), + ); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -225,6 +244,7 @@ export function CloudPanel() { setShowDestroy(false); setDeleteText(""); setDeployResult(null); + setCloudImageUpdate(null); setDestroyed(true); await refreshRailway(); } catch (err) { @@ -481,6 +501,16 @@ export function CloudPanel() { settings.cloud.serverUrl, )} + {cloudImageUpdate?.updateAvailable && ( +
+ Control plane{" "} + {cloudImageUpdate.current} →{" "} + {cloudImageUpdate.latest} available +
+ )}