diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index c722d851..cb3730c7 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -20,6 +20,8 @@ import ( nodepkg "github.com/1024XEngineer/anyclaw/pkg/gateway/resources/nodes" inputlayer "github.com/1024XEngineer/anyclaw/pkg/input" inputchannels "github.com/1024XEngineer/anyclaw/pkg/input/channels" + "github.com/1024XEngineer/anyclaw/pkg/marketplace" + marketregistry "github.com/1024XEngineer/anyclaw/pkg/marketplace/registry" routeingress "github.com/1024XEngineer/anyclaw/pkg/route/ingress" "github.com/1024XEngineer/anyclaw/pkg/runtime" sessionrunner "github.com/1024XEngineer/anyclaw/pkg/runtime/sessionrunner" @@ -45,6 +47,7 @@ type Server struct { signal *inputchannels.SignalAdapter ingress *routeingress.Service runtimePool *runtime.RuntimePool + hotReload *runtime.HotReloadCoordinator sessionRunner *sessionrunner.Manager tasks *taskrunner.Manager storeModule agentstore.StoreManager @@ -69,6 +72,8 @@ type Server struct { mcpRegistry *mcp.Registry mcpServer *mcp.Server marketStore *plugin.Store + marketJobs *marketplace.Store + marketRegistry *marketregistry.Client discoverySvc *discovery.Service mentionGate *inputlayer.MentionGate groupSecurity *inputlayer.GroupSecurity diff --git a/pkg/gateway/gateway_constructor.go b/pkg/gateway/gateway_constructor.go index deffe5b7..5f56ed3f 100644 --- a/pkg/gateway/gateway_constructor.go +++ b/pkg/gateway/gateway_constructor.go @@ -10,6 +10,8 @@ import ( appsecurity "github.com/1024XEngineer/anyclaw/pkg/gateway/auth/security" gatewaymiddleware "github.com/1024XEngineer/anyclaw/pkg/gateway/middleware" nodepkg "github.com/1024XEngineer/anyclaw/pkg/gateway/resources/nodes" + "github.com/1024XEngineer/anyclaw/pkg/marketplace" + marketregistry "github.com/1024XEngineer/anyclaw/pkg/marketplace/registry" "github.com/1024XEngineer/anyclaw/pkg/runtime" sessionrunner "github.com/1024XEngineer/anyclaw/pkg/runtime/sessionrunner" taskrunner "github.com/1024XEngineer/anyclaw/pkg/runtime/taskrunner" @@ -36,8 +38,10 @@ func New(mainRuntime *runtime.MainRuntime) *Server { jobMaxAttempts: mainRuntime.Config.Gateway.JobMaxAttempts, webhooks: newWebhookHandler(), nodes: newNodeManager(), + marketJobs: marketplace.NewStore(mainRuntime.WorkDir), devicePairing: newDevicePairing(mainRuntime), } + server.hotReload = runtime.NewHotReloadCoordinator(server.runtimePool, store) server.approvals = state.NewApprovalManager(store) server.sessionRunner = sessionrunner.NewManager(store, server.sessions, server.runtimePool, server.approvals, sessionEventRecorder{server: server}) server.tasks = taskrunner.NewManager(store, server.sessions, server.runtimePool, taskrunner.MainRuntimeInfo{ @@ -49,6 +53,9 @@ func New(mainRuntime *runtime.MainRuntime) *Server { if sm, err := agentstore.NewStoreManager(mainRuntime.WorkDir, mainRuntime.ConfigPath); err == nil { server.storeModule = sm } + if mainRuntime.Config != nil && marketregistry.IsEnabled(mainRuntime.Config.Marketplace) { + server.marketRegistry = marketregistry.NewClientFromConfig(mainRuntime.Config.Marketplace) + } server.openAICompat = newOpenAICompatHandler(server, mainRuntime) return server diff --git a/pkg/gateway/gateway_market_artifacts_api.go b/pkg/gateway/gateway_market_artifacts_api.go new file mode 100644 index 00000000..e7e7fb3c --- /dev/null +++ b/pkg/gateway/gateway_market_artifacts_api.go @@ -0,0 +1,287 @@ +package gateway + +import ( + "errors" + "net/http" + "strings" + + "github.com/1024XEngineer/anyclaw/pkg/clihub" + "github.com/1024XEngineer/anyclaw/pkg/marketplace" + marketregistry "github.com/1024XEngineer/anyclaw/pkg/marketplace/registry" +) + +func (s *Server) handleMarketArtifacts(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if s == nil || s.mainRuntime == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "runtime not available"}) + return + } + + filter := marketplace.Filter{ + Kind: marketplace.NormalizeKind(r.URL.Query().Get("kind")), + Source: marketplace.NormalizeSource(r.URL.Query().Get("source")), + Query: strings.TrimSpace(r.URL.Query().Get("q")), + Status: marketplace.NormalizeStatus(r.URL.Query().Get("status")), + Risk: strings.TrimSpace(r.URL.Query().Get("risk")), + Trust: strings.TrimSpace(r.URL.Query().Get("trust")), + Tag: strings.TrimSpace(r.URL.Query().Get("tag")), + Permission: strings.TrimSpace(r.URL.Query().Get("permission")), + Publisher: strings.TrimSpace(r.URL.Query().Get("publisher")), + OS: strings.TrimSpace(r.URL.Query().Get("os")), + Arch: strings.TrimSpace(r.URL.Query().Get("arch")), + Sort: strings.TrimSpace(r.URL.Query().Get("sort")), + Limit: parseIntParam(r.URL.Query().Get("limit"), 50), + Offset: parseIntParam(r.URL.Query().Get("offset"), 0), + } + if filter.Source == marketplace.SourceCloud { + result, cloudErr := s.listCloudMarketArtifacts(r, filter) + if cloudErr != "" { + writeJSON(w, http.StatusOK, map[string]any{ + "data": result, + "meta": map[string]any{ + "cloud_error": cloudErr, + }, + }) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": result}) + return + } + catalog := marketplace.NewLocalCatalog(marketplace.LocalCatalogDeps{ + Config: s.mainRuntime.Config, + Skills: s.mainRuntime.Skills, + Plugins: s.plugins, + AgentStore: s.storeModule, + CLIHub: s.loadCLIHubCatalog(), + }) + result, err := catalog.List(r.Context(), filter) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + result.Items = s.marketplaceStore().OverlayStatus(result.Items) + + writeJSON(w, http.StatusOK, map[string]any{ + "data": result, + }) +} + +func (s *Server) handleMarketArtifactDetail(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if s == nil || s.mainRuntime == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "runtime not available"}) + return + } + + id, versions := parseMarketArtifactPath(r.URL.Path) + if id == "" { + http.Error(w, "artifact id required", http.StatusBadRequest) + return + } + + if s.shouldUseCloudMarketArtifact(r, id) { + if versions { + items, err := s.cloudMarketVersions(r, id) + if err != nil { + if errors.Is(err, marketregistry.ErrNotConfigured) || errors.Is(err, marketregistry.ErrRemoteDisabled) { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "cloud registry unavailable"}) + return + } + if status, ok := marketregistry.HTTPStatusCode(err); ok && status == http.StatusNotFound { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "artifact not found"}) + return + } + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"items": items, "total": len(items)}}) + return + } + + artifact, err := s.cloudMarketArtifact(r, id) + if err != nil { + if errors.Is(err, marketregistry.ErrNotConfigured) || errors.Is(err, marketregistry.ErrRemoteDisabled) { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "cloud registry unavailable"}) + return + } + if status, ok := marketregistry.HTTPStatusCode(err); ok && status == http.StatusNotFound { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "artifact not found"}) + return + } + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + overlaid := s.marketplaceStore().OverlayStatus([]marketplace.Artifact{*artifact}) + if len(overlaid) > 0 { + artifact = &overlaid[0] + } + writeJSON(w, http.StatusOK, map[string]any{"data": artifact}) + return + } + + catalog := s.localMarketCatalog() + + if versions { + items, err := catalog.Versions(r.Context(), id) + if err != nil { + if errors.Is(err, marketplace.ErrArtifactNotFound) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "artifact not found"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"items": items}}) + return + } + + artifact, err := catalog.Get(r.Context(), id) + if err != nil { + if errors.Is(err, marketplace.ErrArtifactNotFound) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "artifact not found"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + overlaid := s.marketplaceStore().OverlayStatus([]marketplace.Artifact{*artifact}) + if len(overlaid) > 0 { + artifact = &overlaid[0] + } + writeJSON(w, http.StatusOK, map[string]any{"data": artifact}) +} + +func (s *Server) localMarketCatalog() *marketplace.LocalCatalog { + return marketplace.NewLocalCatalog(marketplace.LocalCatalogDeps{ + Config: s.mainRuntime.Config, + Skills: s.mainRuntime.Skills, + Plugins: s.plugins, + AgentStore: s.storeModule, + CLIHub: s.loadCLIHubCatalog(), + }) +} + +func (s *Server) cloudRegistryClient() *marketregistry.Client { + if s == nil || s.mainRuntime == nil || s.mainRuntime.Config == nil { + return nil + } + if !marketregistry.IsEnabled(s.mainRuntime.Config.Marketplace) { + return nil + } + if s.marketRegistry == nil { + s.marketRegistry = marketregistry.NewClientFromConfig(s.mainRuntime.Config.Marketplace) + } + return s.marketRegistry +} + +func (s *Server) listCloudMarketArtifacts(r *http.Request, filter marketplace.Filter) (marketplace.ListResult, string) { + client := s.cloudRegistryClient() + if client == nil { + return emptyMarketList(filter), "cloud registry endpoint is not configured" + } + filter.Source = "" + result, err := client.List(r.Context(), filter) + if err != nil { + return emptyMarketList(filter), err.Error() + } + s.overlayCloudMarketStatus(r, &result, filter) + applyMarketStatusFilter(&result, filter.Status) + return result, "" +} + +func (s *Server) overlayCloudMarketStatus(r *http.Request, result *marketplace.ListResult, filter marketplace.Filter) { + if s == nil || result == nil || len(result.Items) == 0 { + return + } + result.Items = s.marketplaceStore().OverlayStatus(result.Items) +} + +func applyMarketStatusFilter(result *marketplace.ListResult, status marketplace.ArtifactStatus) { + if result == nil || status == "" { + return + } + items := result.Items[:0] + for _, item := range result.Items { + if item.Status == status { + items = append(items, item) + } + } + result.Items = items + result.Total = len(items) +} + +func (s *Server) cloudMarketArtifact(r *http.Request, id string) (*marketplace.Artifact, error) { + client := s.cloudRegistryClient() + if client == nil { + return nil, marketregistry.ErrNotConfigured + } + return client.Get(r.Context(), id) +} + +func (s *Server) cloudMarketVersions(r *http.Request, id string) ([]marketplace.ArtifactVersion, error) { + client := s.cloudRegistryClient() + if client == nil { + return nil, marketregistry.ErrNotConfigured + } + return client.Versions(r.Context(), id) +} + +func (s *Server) shouldUseCloudMarketArtifact(r *http.Request, id string) bool { + trimmed := strings.TrimSpace(id) + if trimmed == "" { + return false + } + if strings.EqualFold(r.URL.Query().Get("source"), string(marketplace.SourceCloud)) { + return true + } + if strings.HasPrefix(strings.ToLower(trimmed), "cloud.") { + return true + } + if s.cloudRegistryClient() == nil { + return false + } + _, err := s.localMarketCatalog().Get(r.Context(), trimmed) + return errors.Is(err, marketplace.ErrArtifactNotFound) +} + +func emptyMarketList(filter marketplace.Filter) marketplace.ListResult { + limit := filter.Limit + if limit <= 0 { + limit = 50 + } + offset := filter.Offset + if offset < 0 { + offset = 0 + } + return marketplace.ListResult{Items: []marketplace.Artifact{}, Total: 0, Limit: limit, Offset: offset} +} + +func parseMarketArtifactPath(path string) (string, bool) { + trimmed := strings.Trim(strings.TrimPrefix(path, "/market/artifacts/"), "/") + if trimmed == "" || trimmed == path { + return "", false + } + parts := strings.Split(trimmed, "/") + if len(parts) == 2 && parts[1] == "versions" { + return parts[0], true + } + return parts[0], false +} + +func (s *Server) loadCLIHubCatalog() *clihub.Catalog { + if s == nil || s.mainRuntime == nil { + return nil + } + catalog, err := clihub.LoadAuto(s.mainRuntime.WorkingDir) + if err != nil { + return nil + } + return catalog +} diff --git a/pkg/gateway/gateway_market_artifacts_cloud_test.go b/pkg/gateway/gateway_market_artifacts_cloud_test.go new file mode 100644 index 00000000..b4159d50 --- /dev/null +++ b/pkg/gateway/gateway_market_artifacts_cloud_test.go @@ -0,0 +1,360 @@ +package gateway + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/1024XEngineer/anyclaw/pkg/config" + "github.com/1024XEngineer/anyclaw/pkg/marketplace" + appRuntime "github.com/1024XEngineer/anyclaw/pkg/runtime" +) + +func TestMarketArtifactsCloudUsesRegistryEndpoint(t *testing.T) { + registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/artifacts" { + t.Fatalf("unexpected registry path %s", r.URL.Path) + } + if r.URL.Query().Get("kind") != "skill" { + t.Fatalf("expected kind=skill, got %q", r.URL.Query().Get("kind")) + } + want := map[string]string{ + "arch": "amd64", + "os": "windows", + "permission": "fs.read", + "publisher": "AnyClaw Labs", + "q": "release", + "risk": "low", + "sort": "updated", + "tag": "docs", + "trust": "verified", + } + for key, value := range want { + if got := r.URL.Query().Get(key); got != value { + t.Fatalf("expected %s=%q, got %q", key, value, got) + } + } + writeRegistryJSON(t, w, map[string]any{ + "data": map[string]any{ + "items": []map[string]any{{ + "id": "cloud.skill.release-notes", + "kind": "skill", + "name": "Release Notes Writer", + "summary": "Writes release notes.", + "latest_version": "1.0.0", + "source": "anyclaw-cloud", + "publisher": "AnyClaw Labs", + "risk_level": "low", + "trust_level": "verified", + "permissions": []string{"fs.read"}, + "compatibility": map[string]any{"anyclaw_min": "0.1.0"}, + }}, + "total": 1, + "limit": 50, + "offset": 0, + }, + }) + })) + defer registry.Close() + + server := newCloudMarketTestServer(t, registry.URL) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/market/artifacts?source=cloud&kind=skill&q=release&risk=low&trust=verified&tag=docs&permission=fs.read&publisher=AnyClaw%20Labs&os=windows&arch=amd64&sort=updated", nil) + server.handleMarketArtifacts(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + + var payload struct { + Data struct { + Items []struct { + ID string `json:"id"` + Source string `json:"source"` + Status string `json:"status"` + Owner string `json:"owner"` + Enabled bool `json:"enabled"` + } `json:"items"` + Total int `json:"total"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload.Data.Total != 1 || len(payload.Data.Items) != 1 { + t.Fatalf("unexpected payload: %#v", payload.Data) + } + item := payload.Data.Items[0] + if item.ID != "cloud.skill.release-notes" || item.Source != "cloud" || item.Status != "available" || !item.Enabled { + t.Fatalf("unexpected cloud artifact: %#v", item) + } + if item.Owner != "AnyClaw Labs" { + t.Fatalf("unexpected owner %q", item.Owner) + } +} + +func TestMarketArtifactsCloudUnavailableDegradesToEmptyList(t *testing.T) { + server := newCloudMarketTestServer(t, "http://127.0.0.1:1") + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/market/artifacts?source=cloud&kind=agent", nil) + server.handleMarketArtifacts(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + + var payload struct { + Data struct { + Items []any `json:"items"` + Total int `json:"total"` + } `json:"data"` + Meta struct { + CloudError string `json:"cloud_error"` + } `json:"meta"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload.Data.Total != 0 || len(payload.Data.Items) != 0 { + t.Fatalf("expected empty degraded list, got %#v", payload.Data) + } + if payload.Meta.CloudError == "" { + t.Fatal("expected cloud_error metadata") + } +} + +func TestMarketArtifactsCloudStatusFilterAppliesAfterOverlay(t *testing.T) { + registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("status"); got != "" { + t.Fatalf("status filter should not be sent to remote registry before overlay, got %q", got) + } + writeRegistryJSON(t, w, map[string]any{ + "data": map[string]any{ + "items": []map[string]any{ + { + "id": "cloud.skill.installed", + "kind": "skill", + "name": "Installed", + "summary": "Installed cloud skill.", + "latest_version": "1.0.0", + "source": "anyclaw-cloud", + }, + { + "id": "cloud.skill.available", + "kind": "skill", + "name": "Available", + "summary": "Available cloud skill.", + "latest_version": "1.0.0", + "source": "anyclaw-cloud", + }, + }, + "total": 2, + "limit": 50, + "offset": 0, + }, + }) + })) + defer registry.Close() + + server := newCloudMarketTestServer(t, registry.URL) + store := server.marketplaceStore() + if err := store.SaveReceipt(&marketplace.InstallReceipt{ + ID: "cloud.skill.installed@1.0.0", + ArtifactID: "cloud.skill.installed", + Kind: marketplace.ArtifactKindSkill, + Name: "Installed", + Version: "1.0.0", + Source: marketplace.SourceCloud, + InstalledPath: filepath.Join(t.TempDir(), "installed"), + InstalledBy: "user", + InstalledAt: time.Now().UTC().Format(time.RFC3339), + }); err != nil { + t.Fatal(err) + } + + rec := httptest.NewRecorder() + server.handleMarketArtifacts(rec, httptest.NewRequest(http.MethodGet, "/market/artifacts?source=cloud&status=installed", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var payload struct { + Data struct { + Items []struct { + ID string `json:"id"` + Status string `json:"status"` + } `json:"items"` + Total int `json:"total"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload.Data.Total != 1 || len(payload.Data.Items) != 1 || payload.Data.Items[0].ID != "cloud.skill.installed" || payload.Data.Items[0].Status != "installed" { + t.Fatalf("unexpected filtered payload: %#v", payload.Data) + } +} + +func TestMarketArtifactCloudDetailAndVersions(t *testing.T) { + registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/artifacts/anyclaw.agent.marketplace-operator": + writeRegistryJSON(t, w, map[string]any{"data": map[string]any{ + "id": "anyclaw.agent.marketplace-operator", + "kind": "agent", + "name": "Marketplace Operator", + "summary": "Runs marketplace releases.", + "latest_version": "1.0.0", + "source": "anyclaw-cloud", + "publisher": "AnyClaw Labs", + "risk_level": "medium", + "trust_level": "verified", + "permissions": []string{"fs.read"}, + "compatibility": map[string]any{"anyclaw_min": "0.1.0"}, + }}) + case "/v1/artifacts/anyclaw.agent.marketplace-operator/versions": + writeRegistryJSON(t, w, map[string]any{"data": map[string]any{ + "items": []map[string]any{{ + "version": "1.0.0", + "size_bytes": 128, + "released_at": "2026-05-07T00:00:00Z", + }}, + "total": 1, + }}) + default: + http.NotFound(w, r) + } + })) + defer registry.Close() + + server := newCloudMarketTestServer(t, registry.URL) + rec := httptest.NewRecorder() + server.handleMarketArtifactDetail(rec, httptest.NewRequest(http.MethodGet, "/market/artifacts/anyclaw.agent.marketplace-operator?source=cloud", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("detail status = %d, body = %s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + server.handleMarketArtifactDetail(rec, httptest.NewRequest(http.MethodGet, "/market/artifacts/anyclaw.agent.marketplace-operator/versions?source=cloud", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("versions status = %d, body = %s", rec.Code, rec.Body.String()) + } +} + +func TestMarketArtifactCloudDetailMapsRemoteNotFound(t *testing.T) { + registry := httptest.NewServer(http.NotFoundHandler()) + defer registry.Close() + + server := newCloudMarketTestServer(t, registry.URL) + rec := httptest.NewRecorder() + server.handleMarketArtifactDetail(rec, httptest.NewRequest(http.MethodGet, "/market/artifacts/cloud.skill.missing", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("detail status = %d, body = %s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + server.handleMarketArtifactDetail(rec, httptest.NewRequest(http.MethodGet, "/market/artifacts/cloud.skill.missing/versions", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("versions status = %d, body = %s", rec.Code, rec.Body.String()) + } +} + +func TestMarketArtifactCloudDetailFallsBackToCloudForUnknownNonCloudPrefix(t *testing.T) { + registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/artifacts/anyclaw.skill.skill-author" { + t.Fatalf("unexpected registry path %s", r.URL.Path) + } + writeRegistryJSON(t, w, map[string]any{"data": map[string]any{ + "id": "anyclaw.skill.skill-author", + "kind": "skill", + "name": "Skill Author", + "summary": "Creates marketplace-ready skills.", + "latest_version": "1.0.0", + "source": "anyclaw-cloud", + "publisher": "AnyClaw Labs", + "risk_level": "low", + "trust_level": "verified", + "permissions": []string{"fs.read"}, + "compatibility": map[string]any{"anyclaw_min": "0.1.0"}, + }}) + })) + defer registry.Close() + + server := newCloudMarketTestServer(t, registry.URL) + rec := httptest.NewRecorder() + server.handleMarketArtifactDetail(rec, httptest.NewRequest(http.MethodGet, "/market/artifacts/anyclaw.skill.skill-author", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("detail status = %d, body = %s", rec.Code, rec.Body.String()) + } +} + +func TestMarketArtifactHandlersValidateMethodRuntimeAndPaths(t *testing.T) { + rec := httptest.NewRecorder() + (*Server)(nil).handleMarketArtifacts(rec, httptest.NewRequest(http.MethodGet, "/market/artifacts", nil)) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("nil artifacts status = %d", rec.Code) + } + rec = httptest.NewRecorder() + newCloudMarketTestServer(t, "").handleMarketArtifacts(rec, httptest.NewRequest(http.MethodPost, "/market/artifacts", nil)) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("method status = %d", rec.Code) + } + rec = httptest.NewRecorder() + newCloudMarketTestServer(t, "").handleMarketArtifactDetail(rec, httptest.NewRequest(http.MethodGet, "/market/artifacts", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("collection path status = %d", rec.Code) + } + rec = httptest.NewRecorder() + (*Server)(nil).handleMarketArtifactDetail(rec, httptest.NewRequest(http.MethodGet, "/market/artifacts/skill:missing", nil)) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("nil detail status = %d", rec.Code) + } +} + +func TestMarketArtifactHelpers(t *testing.T) { + if id, versions := parseMarketArtifactPath("/market/artifacts/skill:release/versions"); id != "skill:release" || !versions { + t.Fatalf("unexpected versions path parse id=%q versions=%v", id, versions) + } + if id, versions := parseMarketArtifactPath("/market/artifacts/"); id != "" || versions { + t.Fatalf("unexpected empty path parse id=%q versions=%v", id, versions) + } + empty := emptyMarketList(marketplace.Filter{Limit: -1, Offset: -4}) + if empty.Limit != 50 || empty.Offset != 0 || empty.Total != 0 || len(empty.Items) != 0 { + t.Fatalf("unexpected empty list: %#v", empty) + } + server := newCloudMarketTestServer(t, "") + if server.cloudRegistryClient() != nil { + t.Fatal("expected nil cloud client without endpoint") + } + if !server.shouldUseCloudMarketArtifact(httptest.NewRequest(http.MethodGet, "/market/artifacts/cloud.skill.x", nil), "cloud.skill.x") { + t.Fatal("expected cloud prefix to use cloud") + } + if !server.shouldUseCloudMarketArtifact(httptest.NewRequest(http.MethodGet, "/market/artifacts/local?source=cloud", nil), "local") { + t.Fatal("expected source=cloud to use cloud") + } + if _, err := server.cloudMarketArtifact(httptest.NewRequest(http.MethodGet, "/market/artifacts/x", nil), "x"); err == nil { + t.Fatal("expected missing cloud client error") + } +} + +func newCloudMarketTestServer(t *testing.T, endpoint string) *Server { + t.Helper() + cfg := config.DefaultConfig() + cfg.Marketplace.RegistryEndpoint = endpoint + cfg.Marketplace.RequestTimeoutSeconds = 1 + cfg.Marketplace.CacheTTLSeconds = 0 + return &Server{ + mainRuntime: &appRuntime.MainRuntime{ + Config: cfg, + WorkingDir: t.TempDir(), + }, + } +} + +func writeRegistryJSON(t *testing.T, w http.ResponseWriter, value any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(value); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/gateway/gateway_market_store.go b/pkg/gateway/gateway_market_store.go new file mode 100644 index 00000000..4fe28815 --- /dev/null +++ b/pkg/gateway/gateway_market_store.go @@ -0,0 +1,21 @@ +package gateway + +import ( + "strings" + + "github.com/1024XEngineer/anyclaw/pkg/marketplace" +) + +func (s *Server) marketplaceStore() *marketplace.Store { + if s == nil { + return marketplace.NewStore(".") + } + if s.marketJobs == nil { + root := "." + if s.mainRuntime != nil && strings.TrimSpace(s.mainRuntime.WorkDir) != "" { + root = s.mainRuntime.WorkDir + } + s.marketJobs = marketplace.NewStore(root) + } + return s.marketJobs +} diff --git a/pkg/gateway/gateway_routes_platform.go b/pkg/gateway/gateway_routes_platform.go index 07c0d710..5bf482b6 100644 --- a/pkg/gateway/gateway_routes_platform.go +++ b/pkg/gateway/gateway_routes_platform.go @@ -62,6 +62,8 @@ func (s *Server) registerMCPRoutes(mux *http.ServeMux) { } func (s *Server) registerMarketRoutes(mux *http.ServeMux) { + mux.HandleFunc("/market/artifacts", s.wrap("/market/artifacts", requirePermission("market.read", s.handleMarketArtifacts))) + mux.HandleFunc("/market/artifacts/", s.wrap("/market/artifacts/", requirePermission("market.read", s.handleMarketArtifactDetail))) mux.HandleFunc("/market/search", s.wrap("/market/search", requirePermission("market.read", s.handleMarketSearch))) mux.HandleFunc("/market/plugins", s.wrap("/market/plugins", requirePermission("market.read", s.handleMarketPlugins))) mux.HandleFunc("/market/plugins/", s.wrap("/market/plugins/", requirePermissionByMethod(map[string]string{ diff --git a/pkg/marketplace/registry/client.go b/pkg/marketplace/registry/client.go index ab01c217..06b91b86 100644 --- a/pkg/marketplace/registry/client.go +++ b/pkg/marketplace/registry/client.go @@ -365,6 +365,14 @@ func retryable(err error) bool { return true } +func HTTPStatusCode(err error) (int, bool) { + var status remoteStatusError + if errors.As(err, &status) { + return status.StatusCode, true + } + return 0, false +} + type remoteStatusError struct { StatusCode int Body string