From 8ab75209d624310fb9d420074a41d9e027eb6ae3 Mon Sep 17 00:00:00 2001 From: Alex Godoroja Date: Sun, 21 Jun 2026 15:15:41 -0700 Subject: [PATCH] =?UTF-8?q?appstore:=20resolve=20bundles=20by=20canonical?= =?UTF-8?q?=20platform=20(darwin/arm64=20=E2=87=84=20macos-arm64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catalogues and tooling across the org use 'macos-arm64'/'linux-x86_64' (homebrew, sdk-swift, openclaw), but resolveBundle only matched the exact Go string 'darwin/arm64'. macOS-silicon nodes (incl. openclaw agents) therefore couldn't find the silicon bundle even when it was published under a friendly key. Add canonicalPlatform() folding every convention (darwin/arm64, macos-arm64, macos-arm, aarch64-darwin, linux-x86_64, …) to os/arch, and match the host against each bundles-map key by canonical form (exact Go key still fast-pathed for back-compat). Tests cover the fold table + alias-keyed resolution. --- cmd/pilotctl/appstore_catalogue.go | 58 ++++++++++++++++--- .../zz_appstore_resolvebundle_test.go | 48 +++++++++++++++ 2 files changed, 99 insertions(+), 7 deletions(-) diff --git a/cmd/pilotctl/appstore_catalogue.go b/cmd/pilotctl/appstore_catalogue.go index 61659ca7..19282ae5 100644 --- a/cmd/pilotctl/appstore_catalogue.go +++ b/cmd/pilotctl/appstore_catalogue.go @@ -120,26 +120,70 @@ type bundleVariant struct { BundleSHA string `json:"bundle_sha256"` } +// canonicalPlatform normalizes a platform string to "os/arch" with os ∈ +// {darwin,linux,windows} and arch ∈ {amd64,arm64}. It accepts every naming +// convention in the ecosystem so a catalogue keyed "macos-arm64" (the org/ +// homebrew/openclaw convention) and one keyed "darwin/arm64" (Go's +// runtime.GOOS/GOARCH) both resolve to the same host. Unrecognized strings +// are returned lower-cased with separators unified, so exact compares still +// work. Examples: "macos-arm64"→"darwin/arm64", "aarch64-darwin"→ +// "darwin/arm64", "macos-arm"→"darwin/arm64", "linux-x86_64"→"linux/amd64". +func canonicalPlatform(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + s = strings.NewReplacer("/", "-", "_", "-", " ", "-").Replace(s) + var os, arch string + switch { + case strings.Contains(s, "darwin"), strings.Contains(s, "macos"), strings.Contains(s, "osx"), strings.Contains(s, "mac"): + os = "darwin" + case strings.Contains(s, "linux"): + os = "linux" + case strings.Contains(s, "windows"), strings.Contains(s, "win"): + os = "windows" + } + switch { + case strings.Contains(s, "arm64"), strings.Contains(s, "aarch64"), strings.Contains(s, "silicon"): + arch = "arm64" + case strings.Contains(s, "amd64"), strings.Contains(s, "x86-64"), strings.Contains(s, "x64"), strings.Contains(s, "intel"): + arch = "amd64" + case strings.Contains(s, "arm"): // bare "arm" (e.g. "macos-arm") = Apple Silicon + arch = "arm64" + } + if os != "" && arch != "" { + return os + "/" + arch + } + return s +} + // resolveBundle returns the tarball URL + sha256 to install on THIS host. -// A v3 entry (Bundles populated) is strict: it picks the host's os/arch and -// errors if that platform wasn't published, rather than silently fetching a -// binary that can't exec. A v1/v2 entry (no Bundles) uses the single -// top-level BundleURL/BundleSHA, exactly as before. +// A v3 entry (Bundles populated) picks the host's os/arch — matching by +// canonical platform so any naming convention (darwin/arm64, macos-arm64, +// aarch64-darwin, …) resolves — and errors if that platform wasn't published, +// rather than silently fetching a binary that can't exec. A v1/v2 entry (no +// Bundles) uses the single top-level BundleURL/BundleSHA, exactly as before. func (e catalogueEntry) resolveBundle() (url, sha string, err error) { if len(e.Bundles) == 0 { return e.BundleURL, e.BundleSHA, nil } - plat := runtime.GOOS + "/" + runtime.GOARCH - if v, ok := e.Bundles[plat]; ok && v.BundleURL != "" { + exact := runtime.GOOS + "/" + runtime.GOARCH + // Fast path: an exact Go-convention key (what older v3 catalogues use). + if v, ok := e.Bundles[exact]; ok && v.BundleURL != "" { return v.BundleURL, v.BundleSHA, nil } + // Tolerant path: match the host against each key by canonical platform, + // so "macos-arm64" et al. resolve on a darwin/arm64 host. + host := canonicalPlatform(exact) + for k, v := range e.Bundles { + if v.BundleURL != "" && canonicalPlatform(k) == host { + return v.BundleURL, v.BundleSHA, nil + } + } avail := make([]string, 0, len(e.Bundles)) for k := range e.Bundles { avail = append(avail, k) } sort.Strings(avail) return "", "", fmt.Errorf("%s has no bundle for this platform (%s); published platforms: %s", - e.ID, plat, strings.Join(avail, ", ")) + e.ID, exact, strings.Join(avail, ", ")) } // catalogueURL returns the URL pilotctl should fetch the catalogue diff --git a/cmd/pilotctl/zz_appstore_resolvebundle_test.go b/cmd/pilotctl/zz_appstore_resolvebundle_test.go index dbd87a58..cd5eeca1 100644 --- a/cmd/pilotctl/zz_appstore_resolvebundle_test.go +++ b/cmd/pilotctl/zz_appstore_resolvebundle_test.go @@ -52,3 +52,51 @@ func TestResolveBundle_MissingHostPlatformErrors(t *testing.T) { t.Fatalf("error should list available platforms, got: %v", err) } } + +// canonicalPlatform folds every ecosystem naming convention to os/arch. +func TestCanonicalPlatform(t *testing.T) { + cases := map[string]string{ + "darwin/arm64": "darwin/arm64", + "macos-arm64": "darwin/arm64", + "macos-arm": "darwin/arm64", + "aarch64-darwin": "darwin/arm64", + "macos/silicon": "darwin/arm64", + "darwin/amd64": "darwin/amd64", + "macos-x86_64": "darwin/amd64", + "macos-intel": "darwin/amd64", + "linux/amd64": "linux/amd64", + "linux-x86_64": "linux/amd64", + "linux/arm64": "linux/arm64", + "aarch64-linux": "linux/arm64", + } + for in, want := range cases { + if got := canonicalPlatform(in); got != want { + t.Errorf("canonicalPlatform(%q) = %q, want %q", in, got, want) + } + } +} + +// A v3 entry keyed by the org "macos-arm64" convention still resolves on a +// darwin/arm64 host (the openclaw-agent bug: silicon nodes couldn't find the +// silicon bundle because the key wasn't the exact Go string). +func TestResolveBundle_AliasKeyResolves(t *testing.T) { + host := runtime.GOOS + "/" + runtime.GOARCH + // Build a map keyed by org-convention names for the current host. + alias := map[string]string{ + "darwin/arm64": "macos-arm64", + "darwin/amd64": "macos-x86_64", + "linux/amd64": "linux-x86_64", + "linux/arm64": "linux-arm64", + }[host] + if alias == "" { + t.Skipf("no alias mapping for host %s", host) + } + e := catalogueEntry{ + ID: "io.pilot.x", + Bundles: map[string]bundleVariant{alias: {BundleURL: "https://h/host.tar.gz", BundleSHA: "hostsha"}}, + } + url, sha, err := e.resolveBundle() + if err != nil || url != "https://h/host.tar.gz" || sha != "hostsha" { + t.Fatalf("alias key %q should resolve on host %s: url=%q sha=%q err=%v", alias, host, url, sha, err) + } +}