From 7655954c53aa51eaa22747ffb22061579b28d1f6 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 6 Aug 2026 14:07:33 -0400 Subject: [PATCH 01/11] feat(af): provision the pinned furrow client from its release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace handle needs furrow on the CALLER's machine, and furrow had no distribution channel, so the only instruction anyone could give was "build it from Rust source" — which meant the feature was unreachable in practice. Download the pinned release asset into ~/.agentfield/bin, verified against the release's SHA256SUMS and written atomically. Unsupported platforms (Windows has no asset; furrow uses std::os::unix unconditionally) are a clean no-op, and the installed version is recorded beside the binary so bumping the pin actually upgrades machines that already have it. Co-Authored-By: Claude Fable 5 --- control-plane/internal/furrow/ensure.go | 194 ++++++++++++++ control-plane/internal/furrow/ensure_test.go | 263 +++++++++++++++++++ 2 files changed, 457 insertions(+) create mode 100644 control-plane/internal/furrow/ensure.go create mode 100644 control-plane/internal/furrow/ensure_test.go diff --git a/control-plane/internal/furrow/ensure.go b/control-plane/internal/furrow/ensure.go new file mode 100644 index 000000000..5891a4171 --- /dev/null +++ b/control-plane/internal/furrow/ensure.go @@ -0,0 +1,194 @@ +// 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/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) + if info, statErr := os.Stat(destination); statErr == nil && info.Mode().IsRegular() && info.Mode().Perm()&0o111 != 0 { + if installed, readErr := os.ReadFile(markerPath); readErr == nil && strings.TrimSpace(string(installed)) == Version { + 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{Timeout: 15 * time.Second} + } + + 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) + } + + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return fmt.Errorf("create furrow bin directory: %w", err) + } + tmp, err := os.CreateTemp(filepath.Dir(destination), ".furrow-*") + if err != nil { + return fmt.Errorf("create temporary furrow file: %w", err) + } + tmpName := tmp.Name() + defer 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 +} + +// 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 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..70f3ab24e --- /dev/null +++ b/control-plane/internal/furrow/ensure_test.go @@ -0,0 +1,263 @@ +package furrow + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" +) + +func TestAssetName(t *testing.T) { + tests := []struct { + goos, goarch, want string + ok bool + }{ + {"linux", "amd64", "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 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 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 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) != 1 || entries[0].Name() != "furrow" || !entries[0].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) != 0 { + t.Fatalf("unexpected artifacts: %+v", entries) + } +} From ad7f4315db4e58ddaba8ffc7f2782ed7dca8e254 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 6 Aug 2026 14:07:33 -0400 Subject: [PATCH 02/11] feat(af): install furrow with the skill that uses it, and expose af furrow ensure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provisioning belongs in the install path rather than in documentation that each caller re-implements. Ensure furrow when the agentfield-use skill installs — best-effort, so a failed download never fails the install — and add an explicit `af furrow ensure` for repair, which does surface the error since someone asking for it by name is owed the failure. Co-Authored-By: Claude Fable 5 --- control-plane/internal/cli/furrow.go | 25 ++++++++++ control-plane/internal/cli/root.go | 1 + .../internal/skillkit/furrow_install_test.go | 46 +++++++++++++++++++ control-plane/internal/skillkit/install.go | 5 ++ .../internal/skillkit/install_all_test.go | 1 + .../internal/skillkit/reconcile_test.go | 1 + 6 files changed, 79 insertions(+) create mode 100644 control-plane/internal/cli/furrow.go create mode 100644 control-plane/internal/skillkit/furrow_install_test.go 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/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/skillkit/furrow_install_test.go b/control-plane/internal/skillkit/furrow_install_test.go new file mode 100644 index 000000000..7bf125a7c --- /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 From 5bc10f0d0c5c1d45dcc5643383af25a434882309 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 6 Aug 2026 13:18:14 -0400 Subject: [PATCH 03/11] fix(skills): sync the embedded agentfield-use mirror The workspace-handle section added in #885 landed in skills/ only, leaving the embedded copy the control plane actually serves 41 lines behind. Three skillkit tests have been failing on main since that merge. Co-Authored-By: Claude Fable 5 --- .../skill_data/agentfield-use/SKILL.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) 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..05f49f1fd 100644 --- a/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md +++ b/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md @@ -210,6 +210,44 @@ 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":"..."} +``` + +Use it only if `furrow` is on PATH (and, for an `ssh://` handle, `furrow-dial`). +Otherwise ignore it silently and work from the result as usual. + +```bash +# ssh:// handle — furrow-dial carries the protocol; nothing else changes +export FURROW_SSH_COMMAND=furrow-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 +318,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. From 18a9a6f576bb155c0e5f2f5a5350e5ae0c915869 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 6 Aug 2026 10:57:59 -0400 Subject: [PATCH 04/11] feat(control-plane): expose furrow address in health The desktop's workspace-sync probe (PR #885) reads furrow_public_addr from the health response body, but nothing emitted the field, so the probe could never report availability. Emit it from the shared health handler when the FURROW_PUBLIC_ADDR env var is set; omit the key entirely when it is not. Co-Authored-By: Claude Fable 5 --- control-plane/internal/server/routes_core.go | 4 ++ control-plane/internal/server/server_test.go | 41 ++++++++++++++++++++ 2 files changed, 45 insertions(+) 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{ From ced8a8bc488e14264b70f58e27d11502adae1214 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 6 Aug 2026 13:18:35 -0400 Subject: [PATCH 05/11] docs(skill): resolve furrow from where AgentField installs it furrow has no release channel today, so "use it only if furrow is on PATH" silently disabled the workspace handle for every caller. Point the lookup at `~/.agentfield/bin` (where provisioning puts it) and at a node's own vendored copy, and keep the silent-skip when neither exists. Provisioning itself belongs in the install path, not in this document. Co-Authored-By: Claude Fable 5 --- .../skill_data/agentfield-use/SKILL.md | 32 ++++++++++++++----- skills/agentfield-use/SKILL.md | 32 ++++++++++++++----- 2 files changed, 48 insertions(+), 16 deletions(-) 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 05f49f1fd..122ab272b 100644 --- a/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md +++ b/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md @@ -223,23 +223,39 @@ when it works and absent when it doesn't. "namespace":"...", "key":"<64 hex>", "token":"..."} ``` -Use it only if `furrow` is on PATH (and, for an `ssh://` handle, `furrow-dial`). -Otherwise ignore it silently and work from the result as usual. +`furrow` is rarely on PATH. AgentField installs it to `~/.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. + +```bash +os=$(uname -s | tr A-Z a-z) +furrow_bin() { # $1 = furrow | furrow-dial + command -v "$1" 2>/dev/null && return + for c in ~/.agentfield/bin/"$1" ~/.agentfield/packages/*/{bin,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=furrow-dial FURROW_DIAL_TOKEN= FURROW_DIAL_INSECURE=1 -FURROW_RECOVERY_KEY= furrow clone / ./run-workspace --no-watch +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 +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 +`"$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. diff --git a/skills/agentfield-use/SKILL.md b/skills/agentfield-use/SKILL.md index 05f49f1fd..122ab272b 100644 --- a/skills/agentfield-use/SKILL.md +++ b/skills/agentfield-use/SKILL.md @@ -223,23 +223,39 @@ when it works and absent when it doesn't. "namespace":"...", "key":"<64 hex>", "token":"..."} ``` -Use it only if `furrow` is on PATH (and, for an `ssh://` handle, `furrow-dial`). -Otherwise ignore it silently and work from the result as usual. +`furrow` is rarely on PATH. AgentField installs it to `~/.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. + +```bash +os=$(uname -s | tr A-Z a-z) +furrow_bin() { # $1 = furrow | furrow-dial + command -v "$1" 2>/dev/null && return + for c in ~/.agentfield/bin/"$1" ~/.agentfield/packages/*/{bin,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=furrow-dial FURROW_DIAL_TOKEN= FURROW_DIAL_INSECURE=1 -FURROW_RECOVERY_KEY= furrow clone / ./run-workspace --no-watch +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 +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 +`"$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. From 2f27d5ac9346c5f7808d0c7fed1e27e9f45b682f Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 6 Aug 2026 15:13:40 -0400 Subject: [PATCH 06/11] fix(furrow): serialize concurrent installs and stop timing out slow downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two processes running Ensure at once (a desktop skill sync racing a manual af skill install, possibly different af versions) could interleave the binary rename and marker write, leaving an old binary marked as current — permanently skipping the repair. An flock around the whole check-download- install sequence serializes them, and the loser re-checks under the lock so it skips instead of re-downloading. The 15s client timeout bounded the entire request including the ~7.5MB body, failing spuriously below ~500KB/s. Phase timeouts (dial 10s, TLS 10s, response header 30s) with a 3-minute ceiling replace it. Co-Authored-By: Claude Fable 5 --- control-plane/internal/furrow/ensure.go | 39 ++++++++++--- control-plane/internal/furrow/ensure_test.go | 60 +++++++++++++++++++- control-plane/internal/furrow/lock_other.go | 7 +++ control-plane/internal/furrow/lock_unix.go | 20 +++++++ 4 files changed, 115 insertions(+), 11 deletions(-) create mode 100644 control-plane/internal/furrow/lock_other.go create mode 100644 control-plane/internal/furrow/lock_unix.go diff --git a/control-plane/internal/furrow/ensure.go b/control-plane/internal/furrow/ensure.go index 5891a4171..7559cadfd 100644 --- a/control-plane/internal/furrow/ensure.go +++ b/control-plane/internal/furrow/ensure.go @@ -8,6 +8,7 @@ import ( "encoding/hex" "fmt" "io" + "net" "net/http" "os" "path/filepath" @@ -77,10 +78,17 @@ func Ensure(opts Options) error { } destination := filepath.Join(home, "bin", "furrow") markerPath := filepath.Join(home, "bin", versionMarker) - if info, statErr := os.Stat(destination); statErr == nil && info.Mode().IsRegular() && info.Mode().Perm()&0o111 != 0 { - if installed, readErr := os.ReadFile(markerPath); readErr == nil && strings.TrimSpace(string(installed)) == Version { - return nil - } + 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 unlock() + if alreadyInstalled(destination, markerPath) { + return nil } baseURL := strings.TrimRight(opts.BaseURL, "/") @@ -92,7 +100,14 @@ func Ensure(opts Options) error { } client := opts.Client if client == nil { - client = &http.Client{Timeout: 15 * time.Second} + 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") @@ -112,10 +127,7 @@ func Ensure(opts Options) error { return fmt.Errorf("checksum mismatch for %s", asset) } - if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { - return fmt.Errorf("create furrow bin directory: %w", err) - } - tmp, err := os.CreateTemp(filepath.Dir(destination), ".furrow-*") + tmp, err := os.CreateTemp(binDir, ".furrow-*") if err != nil { return fmt.Errorf("create temporary furrow file: %w", err) } @@ -141,6 +153,15 @@ func Ensure(opts Options) error { 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 { diff --git a/control-plane/internal/furrow/ensure_test.go b/control-plane/internal/furrow/ensure_test.go index 70f3ab24e..220d7cd26 100644 --- a/control-plane/internal/furrow/ensure_test.go +++ b/control-plane/internal/furrow/ensure_test.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "strings" + "sync" "sync/atomic" "testing" ) @@ -132,6 +133,61 @@ func TestEnsureRecordsVersionSoTheNextRunSkips(t *testing.T) { } } +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)) @@ -173,7 +229,7 @@ func TestEnsureAtomicRenameFailureLeavesNoPartialFile(t *testing.T) { if readErr != nil { t.Fatal(readErr) } - if len(entries) != 1 || entries[0].Name() != "furrow" || !entries[0].IsDir() { + if len(entries) != 2 || entries[0].Name() != ".furrow.lock" || entries[1].Name() != "furrow" || !entries[1].IsDir() { t.Fatalf("partial artifacts left behind: %+v", entries) } } @@ -257,7 +313,7 @@ func assertNoFurrowArtifacts(t *testing.T, home string) { if err != nil { t.Fatal(err) } - if len(entries) != 0 { + if len(entries) != 1 || entries[0].Name() != ".furrow.lock" { t.Fatalf("unexpected artifacts: %+v", entries) } } 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 +} From 5321e87f942f6356d95c9e86a199c77689e3e136 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 6 Aug 2026 15:13:49 -0400 Subject: [PATCH 07/11] fix(skill): make the furrow resolver POSIX sh, honor AGENTFIELD_HOME, bump to 0.5.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolver snippet used {bin,go/bin} brace expansion, which dash leaves literal — any agent running it under sh would never find furrow-dial inside installed packages. Spell the two package dirs out. It also hardcoded ~/.agentfield while provisioning honors AGENTFIELD_HOME, so a custom home could install furrow somewhere the skill never looks. The catalog says to bump Version on every content change; the furrow sections (here and #885) shipped on 0.4.0, leaving reconcilers no signal. Co-Authored-By: Claude Fable 5 --- control-plane/internal/skillkit/catalog.go | 2 +- .../skillkit/catalog_agentfield_use_test.go | 4 ++-- .../skillkit/skill_data/agentfield-use/SKILL.md | 17 +++++++++++------ .../internal/skillkit/skill_mirror_test.go | 2 +- skills/agentfield-use/SKILL.md | 17 +++++++++++------ 5 files changed, 26 insertions(+), 16 deletions(-) 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/skill_data/agentfield-use/SKILL.md b/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md index 122ab272b..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." --- @@ -223,15 +223,20 @@ when it works and absent when it doesn't. "namespace":"...", "key":"<64 hex>", "token":"..."} ``` -`furrow` is rarely on PATH. AgentField installs it to `~/.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. +`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. -```bash +```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 ~/.agentfield/bin/"$1" ~/.agentfield/packages/*/{bin,go/bin}/"$1"-"$os"-*; do + 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 } 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/skills/agentfield-use/SKILL.md b/skills/agentfield-use/SKILL.md index 122ab272b..bcdc8202c 100644 --- a/skills/agentfield-use/SKILL.md +++ b/skills/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." --- @@ -223,15 +223,20 @@ when it works and absent when it doesn't. "namespace":"...", "key":"<64 hex>", "token":"..."} ``` -`furrow` is rarely on PATH. AgentField installs it to `~/.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. +`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. -```bash +```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 ~/.agentfield/bin/"$1" ~/.agentfield/packages/*/{bin,go/bin}/"$1"-"$os"-*; do + 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 } From 7f2bf8ffbf1ce54053bed562579f3fa33afe577a Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 6 Aug 2026 15:13:49 -0400 Subject: [PATCH 08/11] fix(deploy): opt the cloud image out of furrow client provisioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The furrow client is a laptop-side tool — cloud agents get furrowd vendored by their own packages, and nothing in the container clones workspaces. Any skill install run in the container would otherwise pull ~7.5MB from GitHub onto the volume for no consumer. Co-Authored-By: Claude Fable 5 --- deployments/docker/Dockerfile.control-plane-cloud | 4 ++++ 1 file changed, 4 insertions(+) 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 From 8021d31b2c54b274e1869cf3692b44d1764decf8 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Thu, 6 Aug 2026 15:17:42 -0400 Subject: [PATCH 09/11] feat(desktop): tell users when a cloud upgrade exists, and stop skipping skill sync in cloud mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-run deploy has been a safe upgrade path since source_image pinning, but nothing said an upgrade existed — users had to know the button doubles as one. The panel now compares the deployed pin from Terraform state against the release tag Docker Hub resolves for :latest, shows 'Control plane vX -> vY available', and relabels the action 'Upgrade & redeploy' while one is pending. The connection test also gets a Workspace sync row, kept neutral when the server predates the health field it reads. syncSkills was skipped whenever a cloud profile was active — a guard the cloud-mode PR added wholesale. Skills (and the furrow client their install provisions) belong to local coding agents regardless of where the control plane runs; a cloud-connected laptop is exactly the machine that needs the workspace client. Co-Authored-By: Claude Fable 5 --- desktop/src/main/cloud.test.ts | 2 + desktop/src/main/cloud.ts | 1 + desktop/src/main/deployEngine.test.ts | 51 ++++++++++++++++++- desktop/src/main/deployEngine.ts | 20 ++++++++ desktop/src/main/index.ts | 10 +++- desktop/src/preload/index.ts | 1 + .../renderer/src/components/CloudPanel.tsx | 44 +++++++++++++++- desktop/src/shared/types.ts | 9 ++++ 8 files changed, 134 insertions(+), 4 deletions(-) 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 +
+ )}