From 879780b6fca9b6bc714c99d0b7af54ab14f31afb Mon Sep 17 00:00:00 2001 From: h1177h <2928932863@qq.com> Date: Fri, 8 May 2026 21:11:26 +0800 Subject: [PATCH 1/2] feat: integrate marketplace tools with runtime --- pkg/capability/markettools/marketplace.go | 370 ++++++++++++++++++ .../markettools/marketplace_test.go | 278 +++++++++++++ pkg/runtime/bootstrap.go | 18 + pkg/runtime/hot_reload.go | 169 ++++++++ pkg/runtime/hot_reload_test.go | 81 ++++ pkg/runtime/market_integration.go | 340 ++++++++++++++++ pkg/runtime/marketplace_store.go | 13 + pkg/runtime/pool.go | 28 ++ pkg/runtime/tools_refresh.go | 19 + pkg/runtime/tools_refresh_test.go | 178 +++++++++ 10 files changed, 1494 insertions(+) create mode 100644 pkg/capability/markettools/marketplace.go create mode 100644 pkg/capability/markettools/marketplace_test.go create mode 100644 pkg/runtime/hot_reload.go create mode 100644 pkg/runtime/hot_reload_test.go create mode 100644 pkg/runtime/market_integration.go create mode 100644 pkg/runtime/marketplace_store.go diff --git a/pkg/capability/markettools/marketplace.go b/pkg/capability/markettools/marketplace.go new file mode 100644 index 00000000..98ff6269 --- /dev/null +++ b/pkg/capability/markettools/marketplace.go @@ -0,0 +1,370 @@ +package markettools + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/1024XEngineer/anyclaw/pkg/capability/tools" + "github.com/1024XEngineer/anyclaw/pkg/marketplace" + marketregistry "github.com/1024XEngineer/anyclaw/pkg/marketplace/registry" +) + +type Options struct { + Store *marketplace.Store + Registry *marketregistry.Client + AutoInstallSkill bool + AuditLogger tools.AuditLogger + AfterInstall func(ctx context.Context, receipt *marketplace.InstallReceipt) error + AfterBind func(ctx context.Context, binding *marketplace.Binding) error +} + +func Register(registry *tools.Registry, opts Options) { + if registry == nil || opts.Store == nil { + return + } + registry.Register(&tools.Tool{ + Name: "market_search_artifacts", + Description: "Search installed and cloud marketplace artifacts for a missing capability, returning policy metadata and a recommended route.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]string{"type": "string", "description": "Capability need or search query"}, + "kind": map[string]string{"type": "string", "description": "Optional kind: agent, skill, or cli"}, + "source": map[string]string{"type": "string", "description": "Optional source: local, cloud, or all"}, + "limit": map[string]string{"type": "number", "description": "Maximum results"}, + }, + "required": []string{"query"}, + }, + Category: tools.ToolCategoryCustom, + AccessLevel: tools.ToolAccessPublic, + Visibility: tools.ToolVisibilityMainAgentOnly, + Handler: func(ctx context.Context, input map[string]any) (string, error) { + return audit(opts, "market_search_artifacts", input, func(ctx context.Context, input map[string]any) (string, error) { + return searchArtifacts(ctx, opts, input) + })(ctx, input) + }, + }) + registry.Register(&tools.Tool{ + Name: "market_install_artifact", + Description: "Install a cloud marketplace artifact under local policy. Ask decisions require explicit user_confirmed=true; high-risk permissions also require risk_acknowledged=true.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "artifact_id": map[string]string{"type": "string", "description": "Cloud artifact id"}, + "version_constraint": map[string]string{"type": "string", "description": "Optional exact version or version constraint"}, + "user_confirmed": map[string]string{"type": "boolean", "description": "Set true only after user confirms the policy prompt"}, + "risk_acknowledged": map[string]string{"type": "boolean", "description": "Set true only after user explicitly acknowledges high-risk permissions"}, + }, + "required": []string{"artifact_id"}, + }, + Category: tools.ToolCategoryCustom, + AccessLevel: tools.ToolAccessPublic, + Visibility: tools.ToolVisibilityMainAgentOnly, + RequiresApproval: true, + Handler: func(ctx context.Context, input map[string]any) (string, error) { + return audit(opts, "market_install_artifact", input, func(ctx context.Context, input map[string]any) (string, error) { + if err := tools.RequestToolApproval(ctx, "market_install_artifact", input); err != nil { + return "", err + } + return installArtifact(ctx, opts, input) + })(ctx, input) + }, + }) + registry.Register(&tools.Tool{ + Name: "market_bind_artifact", + Description: "Bind an installed marketplace artifact to main_agent, persistent_subagent, workspace, or runtime_global.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "artifact_id": map[string]string{"type": "string", "description": "Installed artifact id"}, + "target_type": map[string]string{"type": "string", "description": "main_agent, persistent_subagent, workspace, or runtime_global"}, + "target_id": map[string]string{"type": "string", "description": "Optional target id; runtime_global may omit it"}, + }, + "required": []string{"artifact_id", "target_type"}, + }, + Category: tools.ToolCategoryCustom, + AccessLevel: tools.ToolAccessPublic, + Visibility: tools.ToolVisibilityMainAgentOnly, + RequiresApproval: true, + Handler: func(ctx context.Context, input map[string]any) (string, error) { + return audit(opts, "market_bind_artifact", input, func(ctx context.Context, input map[string]any) (string, error) { + if err := tools.RequestToolApproval(ctx, "market_bind_artifact", input); err != nil { + return "", err + } + return bindArtifact(ctx, opts, input) + })(ctx, input) + }, + }) +} + +func searchArtifacts(ctx context.Context, opts Options, input map[string]any) (string, error) { + query := stringValue(input["query"]) + kind := marketplace.NormalizeKind(stringValue(input["kind"])) + source := marketplace.NormalizeSource(stringValue(input["source"])) + limit := intValue(input["limit"], 5) + local, err := localArtifacts(opts.Store, kind, limit) + if err != nil { + return "", err + } + var cloud []marketplace.Artifact + var cloudErr string + if source != marketplace.SourceLocal && opts.Registry != nil { + result, err := opts.Registry.List(ctx, marketplace.Filter{Kind: kind, Query: query, Limit: limit}) + if err != nil { + cloudErr = err.Error() + } else { + cloud = result.Items + } + } + if source == marketplace.SourceCloud { + local = nil + } + route := marketplace.RouteCapabilityNeed(query, local, cloud, limit) + return marshalJSON(map[string]any{ + "query": query, + "kind": kind, + "source": firstNonEmpty(string(source), "all"), + "route": route, + "local_count": len(local), + "cloud_count": len(cloud), + "local": marketplace.BuildCapabilityIndex(local), + "cloud": marketplace.BuildCapabilityIndex(cloud), + "cloud_error": cloudErr, + }) +} + +func installArtifact(ctx context.Context, opts Options, input map[string]any) (string, error) { + if opts.Store == nil || opts.Registry == nil { + return "", fmt.Errorf("marketplace install is not configured") + } + artifactID := strings.TrimSpace(stringValue(input["artifact_id"])) + if artifactID == "" { + return "", fmt.Errorf("artifact_id is required") + } + version := strings.TrimSpace(stringValue(input["version_constraint"])) + userConfirmed := boolValue(input["user_confirmed"]) + riskAcknowledged := boolValue(input["risk_acknowledged"]) + resolved, err := opts.Registry.Resolve(ctx, artifactID, marketregistry.ResolveRequest{VersionConstraint: version}) + if err != nil { + return "", err + } + resolvedPkg := resolvedPackage(resolved) + decision := marketplace.NewDecisionPolicy(marketplace.PolicyConfig{AutoInstallSkill: opts.AutoInstallSkill}).DecideInstall(marketplace.InstallRequest{ + ArtifactID: artifactID, + VersionConstraint: version, + InstalledBy: "agent", + UserConfirmed: userConfirmed, + RiskAcknowledged: riskAcknowledged, + }, resolvedPkg) + if decision.Decision == marketplace.DecisionAsk && (decision.RequiresUserConfirmation || decision.RequiresRiskAcknowledgement) { + _ = opts.Store.AppendAudit(marketplace.MarketAuditEvent{ + Type: "market.agent_install.ask", + ArtifactID: artifactID, + Actor: "agent", + Decision: string(decision.Decision), + Reason: decision.Reason, + Detail: map[string]any{ + "version": resolved.Version, + "risk_level": resolved.RiskLevel, + "trust_level": resolved.TrustLevel, + "permissions": resolved.Permissions, + }, + }) + return marshalJSON(map[string]any{"status": "requires_confirmation", "decision": decision, "artifact": resolvedPkg}) + } + uc := marketplace.NewInstallUseCaseWithPolicy(opts.Store, registryAdapter{client: opts.Registry}, marketplace.PolicyConfig{AutoInstallSkill: opts.AutoInstallSkill}) + job, reused, err := uc.Start(ctx, marketplace.InstallRequest{ + ArtifactID: artifactID, + VersionConstraint: version, + InstalledBy: "agent", + UserConfirmed: userConfirmed, + RiskAcknowledged: riskAcknowledged, + IdempotencyKey: "agent-" + artifactID + "-" + resolved.Version, + }) + if err != nil { + return "", err + } + if !reused { + if err := uc.Execute(ctx, job.ID); err != nil { + latest, _ := opts.Store.GetJob(job.ID) + return marshalJSON(map[string]any{"status": "failed", "job": latest, "error": err.Error()}) + } + } + latest, _ := opts.Store.GetJob(job.ID) + if latest != nil && latest.State == marketplace.JobSucceeded && strings.TrimSpace(latest.ReceiptID) != "" && opts.AfterInstall != nil { + if receipt, receiptErr := opts.Store.GetReceipt(latest.ReceiptID); receiptErr == nil { + if hookErr := opts.AfterInstall(ctx, receipt); hookErr != nil { + return marshalJSON(map[string]any{"status": "installed", "job": latest, "reused": reused, "integration_error": hookErr.Error()}) + } + } + } + return marshalJSON(map[string]any{"status": "installed", "job": latest, "reused": reused}) +} + +func bindArtifact(ctx context.Context, opts Options, input map[string]any) (string, error) { + artifactID := strings.TrimSpace(stringValue(input["artifact_id"])) + targetType := marketplace.NormalizeBindingTargetType(stringValue(input["target_type"])) + if artifactID == "" || targetType == "" { + return "", fmt.Errorf("artifact_id and target_type are required") + } + binding, err := opts.Store.CreateBinding(marketplace.BindingRequest{ + ArtifactID: artifactID, + TargetType: targetType, + TargetID: strings.TrimSpace(stringValue(input["target_id"])), + }) + if err != nil { + return "", err + } + if opts.AfterBind != nil { + if err := opts.AfterBind(ctx, binding); err != nil { + return marshalJSON(map[string]any{"status": "bound", "binding": binding, "refresh_error": err.Error()}) + } + } + _ = opts.Store.AppendAudit(marketplace.MarketAuditEvent{ + Type: "market.agent_bind.succeeded", + ArtifactID: binding.ArtifactID, + BindingID: binding.ID, + Actor: "agent", + Detail: map[string]any{ + "target_type": binding.TargetType, + "target_id": binding.TargetID, + "version": binding.Version, + }, + }) + return marshalJSON(map[string]any{"status": "bound", "binding": binding}) +} + +func localArtifacts(store *marketplace.Store, kind marketplace.ArtifactKind, limit int) ([]marketplace.Artifact, error) { + receipts, err := store.ListReceipts() + if err != nil { + return nil, err + } + items := make([]marketplace.Artifact, 0, len(receipts)) + for _, receipt := range receipts { + if kind != "" && receipt.Kind != kind { + continue + } + items = append(items, marketplace.Artifact{ + ID: receipt.ArtifactID, + Kind: receipt.Kind, + Name: receipt.Name, + DisplayName: receipt.Name, + Version: receipt.Version, + Source: marketplace.SourceLocal, + Status: marketplace.StatusInstalled, + Installed: true, + Enabled: true, + Permissions: append([]string(nil), receipt.Permissions...), + RiskLevel: receipt.RiskLevel, + TrustLevel: receipt.TrustLevel, + Compatibility: receipt.Compatibility, + Dependencies: append([]marketplace.ArtifactDependency(nil), receipt.Dependencies...), + Capabilities: []string{receipt.Name, string(receipt.Kind)}, + }) + if limit > 0 && len(items) >= limit { + break + } + } + return items, nil +} + +type registryAdapter struct { + client *marketregistry.Client +} + +func (a registryAdapter) Resolve(ctx context.Context, artifactID, versionConstraint string) (marketplace.ResolvedPackage, error) { + resolved, err := a.client.Resolve(ctx, artifactID, marketregistry.ResolveRequest{VersionConstraint: versionConstraint}) + if err != nil { + return marketplace.ResolvedPackage{}, err + } + return resolvedPackage(resolved), nil +} + +func (a registryAdapter) Download(ctx context.Context, rawURL string) ([]byte, error) { + return a.client.Download(ctx, rawURL) +} + +func resolvedPackage(resolved marketregistry.ResolvedArtifact) marketplace.ResolvedPackage { + return marketplace.ResolvedPackage{ + ArtifactID: resolved.ArtifactID, + Version: resolved.Version, + DownloadURL: resolved.DownloadURL, + ChecksumSHA256: resolved.ChecksumSHA256, + SizeBytes: resolved.SizeBytes, + Compatibility: resolved.Compatibility, + Dependencies: resolved.Dependencies, + RiskLevel: resolved.RiskLevel, + TrustLevel: resolved.TrustLevel, + Permissions: append([]string(nil), resolved.Permissions...), + Signature: resolved.Signature, + Kind: resolved.Kind, + Name: resolved.Name, + } +} + +func audit(opts Options, toolName string, input map[string]any, next tools.ToolFunc) tools.ToolFunc { + return func(ctx context.Context, _ map[string]any) (string, error) { + output, err := next(ctx, input) + if opts.AuditLogger != nil { + opts.AuditLogger.LogTool(toolName, input, output, err) + } + return output, err + } +} + +func stringValue(value any) string { + if value == nil { + return "" + } + return fmt.Sprint(value) +} + +func boolValue(value any) bool { + switch v := value.(type) { + case bool: + return v + case string: + return strings.EqualFold(v, "true") || strings.EqualFold(v, "1") || strings.EqualFold(v, "yes") + default: + return false + } +} + +func intValue(value any, fallback int) int { + switch v := value.(type) { + case int: + return v + case float64: + return int(v) + case json.Number: + i, err := v.Int64() + if err == nil { + return int(i) + } + case string: + i, err := json.Number(v).Int64() + if err == nil { + return int(i) + } + } + return fallback +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func marshalJSON(value any) (string, error) { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/pkg/capability/markettools/marketplace_test.go b/pkg/capability/markettools/marketplace_test.go new file mode 100644 index 00000000..a9261ce4 --- /dev/null +++ b/pkg/capability/markettools/marketplace_test.go @@ -0,0 +1,278 @@ +package markettools + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/1024XEngineer/anyclaw/pkg/capability/tools" + "github.com/1024XEngineer/anyclaw/pkg/marketplace" + marketregistry "github.com/1024XEngineer/anyclaw/pkg/marketplace/registry" +) + +func TestRegisterMarketplaceToolsMainAgentOnly(t *testing.T) { + registry := tools.NewRegistry() + Register(registry, Options{Store: marketplace.NewStore(t.TempDir())}) + if _, ok := registry.Get("market_search_artifacts"); !ok { + t.Fatal("expected market_search_artifacts tool") + } + if subTools := registry.ListForRole(true); toolListed(subTools, "market_search_artifacts") { + t.Fatalf("market tools should be main-agent only: %#v", subTools) + } + _, err := registry.Call(tools.WithToolCaller(context.Background(), tools.ToolCaller{Role: tools.ToolCallerRoleSubAgent}), "market_search_artifacts", map[string]any{ + "query": "release notes", + }) + if err == nil || !strings.Contains(err.Error(), "not available for caller role sub_agent") { + t.Fatalf("sub-agent call err = %v, want visibility denial", err) + } +} + +func TestSearchToolRoutesMissingCapabilityToCloud(t *testing.T) { + server := testMarketRegistryServer(t, "cloud.skill.release-notes", "skill", "low", "verified", []string{"fs.read"}, nil) + defer server.Close() + + registry := tools.NewRegistry() + Register(registry, Options{ + Store: marketplace.NewStore(t.TempDir()), + Registry: marketregistry.NewClient(marketregistry.ClientConfig{Endpoint: server.URL}), + }) + out, err := registry.Call(tools.WithToolCaller(context.Background(), tools.ToolCaller{Role: tools.ToolCallerRoleMainAgent}), "market_search_artifacts", map[string]any{ + "query": "please write release notes", + "kind": "skill", + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, `"action": "install_from_market"`) || !strings.Contains(out, "cloud.skill.release-notes") { + t.Fatalf("output = %s, want cloud install route", out) + } +} + +func TestInstallToolAskReturnsConfirmationWithoutInstalling(t *testing.T) { + archive := testMarketToolArchive(t, "cloud.agent.code-reviewer", marketplace.ArtifactKindAgent, "1.0.0") + server := testMarketRegistryServer(t, "cloud.agent.code-reviewer", "agent", "medium", "verified", []string{"fs.read"}, archive) + defer server.Close() + + store := marketplace.NewStore(t.TempDir()) + registry := tools.NewRegistry() + Register(registry, Options{Store: store, Registry: marketregistry.NewClient(marketregistry.ClientConfig{Endpoint: server.URL})}) + out, err := registry.Call(tools.WithToolCaller(context.Background(), tools.ToolCaller{Role: tools.ToolCallerRoleMainAgent}), "market_install_artifact", map[string]any{ + "artifact_id": "cloud.agent.code-reviewer", + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "requires_confirmation") { + t.Fatalf("output = %s, want confirmation", out) + } + if _, err := store.LatestReceiptForArtifact("cloud.agent.code-reviewer"); err != marketplace.ErrArtifactNotFound { + t.Fatalf("receipt err = %v, want not found", err) + } +} + +func TestInstallToolConfirmedInstallsAsAgent(t *testing.T) { + archive := testMarketToolArchive(t, "cloud.skill.release-notes", marketplace.ArtifactKindSkill, "1.0.0") + server := testMarketRegistryServer(t, "cloud.skill.release-notes", "skill", "low", "verified", []string{"fs.read"}, archive) + defer server.Close() + + store := marketplace.NewStore(t.TempDir()) + registry := tools.NewRegistry() + Register(registry, Options{Store: store, Registry: marketregistry.NewClient(marketregistry.ClientConfig{Endpoint: server.URL}), AutoInstallSkill: true}) + out, err := registry.Call(tools.WithToolCaller(context.Background(), tools.ToolCaller{Role: tools.ToolCallerRoleMainAgent}), "market_install_artifact", map[string]any{ + "artifact_id": "cloud.skill.release-notes", + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "installed") { + t.Fatalf("output = %s, want installed", out) + } + receipt, err := store.LatestReceiptForArtifact("cloud.skill.release-notes") + if err != nil { + t.Fatal(err) + } + if receipt.InstalledBy != "agent" { + t.Fatalf("installed_by = %q, want agent", receipt.InstalledBy) + } +} + +func TestBindToolCreatesAgentBinding(t *testing.T) { + store := marketplace.NewStore(t.TempDir()) + if err := store.SaveReceipt(&marketplace.InstallReceipt{ + ID: "cloud.skill.release-notes@1.0.0", + ArtifactID: "cloud.skill.release-notes", + Kind: marketplace.ArtifactKindSkill, + Name: "Release Notes", + Version: "1.0.0", + Source: marketplace.SourceCloud, + InstalledPath: t.TempDir(), + InstalledBy: "agent", + InstalledAt: "2026-05-07T00:00:00Z", + }); err != nil { + t.Fatal(err) + } + registry := tools.NewRegistry() + Register(registry, Options{Store: store}) + out, err := registry.Call(tools.WithToolCaller(context.Background(), tools.ToolCaller{Role: tools.ToolCallerRoleMainAgent}), "market_bind_artifact", map[string]any{ + "artifact_id": "cloud.skill.release-notes", + "target_type": "runtime_global", + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "bound") { + t.Fatalf("output = %s, want bound", out) + } +} + +func TestMarketToolsValidationAndHelpers(t *testing.T) { + if _, err := installArtifact(context.Background(), Options{}, map[string]any{"artifact_id": "x"}); err == nil { + t.Fatal("expected install not configured error") + } + if _, err := installArtifact(context.Background(), Options{Store: marketplace.NewStore(t.TempDir()), Registry: marketregistry.NewClient(marketregistry.ClientConfig{Endpoint: "http://127.0.0.1:1"})}, map[string]any{}); err == nil || !strings.Contains(err.Error(), "artifact_id is required") { + t.Fatalf("expected artifact id error, got %v", err) + } + if _, err := bindArtifact(context.Background(), Options{Store: marketplace.NewStore(t.TempDir())}, map[string]any{"artifact_id": "x"}); err == nil || !strings.Contains(err.Error(), "artifact_id and target_type") { + t.Fatalf("expected bind validation error, got %v", err) + } + if stringValue(123) != "123" || !boolValue("true") || boolValue("false") || intValue(float64(7), 1) != 7 || intValue("bad", 9) != 9 { + t.Fatal("market tool scalar helpers mismatch") + } + if firstNonEmpty("", " value ") != "value" { + t.Fatal("firstNonEmpty mismatch") + } + out, err := marshalJSON(map[string]any{"ok": true}) + if err != nil || !strings.Contains(out, `"ok": true`) { + t.Fatalf("marshalJSON = %q err=%v", out, err) + } +} + +func TestSearchArtifactsCloudOnlyAndLocalLimit(t *testing.T) { + archive := testMarketToolArchive(t, "cloud.skill.release-notes", marketplace.ArtifactKindSkill, "1.0.0") + server := testMarketRegistryServer(t, "cloud.skill.release-notes", "skill", "low", "verified", []string{"fs.read"}, archive) + defer server.Close() + + store := marketplace.NewStore(t.TempDir()) + if err := store.SaveReceipt(&marketplace.InstallReceipt{ + ID: "local.skill@1.0.0", + ArtifactID: "local.skill", + Kind: marketplace.ArtifactKindSkill, + Name: "Local Skill", + Version: "1.0.0", + Source: marketplace.SourceCloud, + InstalledPath: t.TempDir(), + InstalledAt: "2026-05-07T00:00:00Z", + }); err != nil { + t.Fatal(err) + } + out, err := searchArtifacts(context.Background(), Options{ + Store: store, + Registry: marketregistry.NewClient(marketregistry.ClientConfig{Endpoint: server.URL}), + }, map[string]any{"query": "release", "kind": "skill", "source": "cloud", "limit": 1}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(out, "Local Skill") || !strings.Contains(out, "cloud.skill.release-notes") { + t.Fatalf("unexpected cloud-only search output: %s", out) + } + local, err := localArtifacts(store, marketplace.ArtifactKindSkill, 1) + if err != nil { + t.Fatal(err) + } + if len(local) != 1 || local[0].ID != "local.skill" { + t.Fatalf("unexpected local artifacts: %#v", local) + } +} + +func toolListed(items []tools.ToolInfo, name string) bool { + for _, item := range items { + if item.Name == name { + return true + } + } + return false +} + +func testMarketRegistryServer(t *testing.T, id, kind, risk, trust string, permissions []string, archive []byte) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/resolve"): + writeMarketToolJSON(t, w, map[string]any{"data": map[string]any{ + "artifact_id": id, + "version": "1.0.0", + "download_url": "http://" + r.Host + "/v1/download/" + id + "/1.0.0", + "checksum_sha256": sha256MarketTool(archive), + "size_bytes": len(archive), + "risk_level": risk, + "trust_level": trust, + "permissions": permissions, + "kind": kind, + "name": id, + }}) + case strings.Contains(r.URL.Path, "/v1/download/"): + _, _ = w.Write(archive) + case r.URL.Path == "/v1/artifacts": + writeMarketToolJSON(t, w, map[string]any{"data": map[string]any{ + "items": []any{map[string]any{ + "id": id, + "kind": kind, + "name": id, + "summary": id, + "version": "1.0.0", + "latest_version": "1.0.0", + "publisher": "AnyClaw", + "risk_level": risk, + "trust_level": trust, + "permissions": permissions, + "tags": []string{"release notes", "changelog", "code review", "pull request"}, + "hit_signals": []string{"release notes", "code review"}, + "score": 0.91, + }}, + "total": 1, + "limit": 10, + "offset": 0, + }}) + default: + http.NotFound(w, r) + } + })) +} + +func testMarketToolArchive(t *testing.T, id string, kind marketplace.ArtifactKind, version string) []byte { + t.Helper() + var buf bytes.Buffer + writer := zip.NewWriter(&buf) + w, err := writer.Create("anyclaw.artifact.json") + if err != nil { + t.Fatal(err) + } + data, _ := json.Marshal(map[string]any{"id": id, "kind": kind, "name": id, "version": version}) + if _, err := w.Write(data); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func writeMarketToolJSON(t *testing.T, w http.ResponseWriter, payload any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatal(err) + } +} + +func sha256MarketTool(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} diff --git a/pkg/runtime/bootstrap.go b/pkg/runtime/bootstrap.go index ed786821..b858c03d 100644 --- a/pkg/runtime/bootstrap.go +++ b/pkg/runtime/bootstrap.go @@ -9,11 +9,14 @@ import ( "time" agent "github.com/1024XEngineer/anyclaw/pkg/capability/agents" + "github.com/1024XEngineer/anyclaw/pkg/capability/markettools" llm "github.com/1024XEngineer/anyclaw/pkg/capability/models" "github.com/1024XEngineer/anyclaw/pkg/capability/skills" "github.com/1024XEngineer/anyclaw/pkg/capability/tools" "github.com/1024XEngineer/anyclaw/pkg/config" "github.com/1024XEngineer/anyclaw/pkg/extensions/plugin" + "github.com/1024XEngineer/anyclaw/pkg/marketplace" + marketregistry "github.com/1024XEngineer/anyclaw/pkg/marketplace/registry" "github.com/1024XEngineer/anyclaw/pkg/qmd" "github.com/1024XEngineer/anyclaw/pkg/runtime/orchestrator" "github.com/1024XEngineer/anyclaw/pkg/state/audit" @@ -339,6 +342,14 @@ func Bootstrap(opts BootstrapOptions) (*MainRuntime, error) { QMDClient: qmdClient, } tools.RegisterBuiltins(registry, builtinOpts) + markettools.Register(registry, markettools.Options{ + Store: marketplace.NewStore(marketplaceStoreRoot(workDir, workingDir)), + Registry: marketplaceRegistryClient(app.Config.Marketplace), + AutoInstallSkill: app.Config.Marketplace.AutoInstallSkill, + AuditLogger: auditLogger, + AfterInstall: app.IntegrateMarketReceiptAndRefresh, + AfterBind: app.RefreshAfterMarketBinding, + }) sk.RegisterTools(registry, skills.ExecutionOptions{AllowExec: app.Config.Plugins.AllowExec, ExecTimeoutSeconds: app.Config.Plugins.ExecTimeoutSeconds}) app.Tools = registry @@ -446,6 +457,13 @@ func buildWorkspaceBootstrapOptions(cfg *config.Config) workspace.BootstrapOptio return opts } +func marketplaceRegistryClient(cfg config.MarketplaceConfig) *marketregistry.Client { + if !marketregistry.IsEnabled(cfg) { + return nil + } + return marketregistry.NewClientFromConfig(cfg) +} + func bootstrapUserProfile(cfg *config.Config) string { if cfg == nil { return "" diff --git a/pkg/runtime/hot_reload.go b/pkg/runtime/hot_reload.go new file mode 100644 index 00000000..88b53466 --- /dev/null +++ b/pkg/runtime/hot_reload.go @@ -0,0 +1,169 @@ +package runtime + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/1024XEngineer/anyclaw/pkg/state" +) + +type RefreshScopeKind string + +const ( + RefreshScopeRuntime RefreshScopeKind = "runtime" + RefreshScopeAgent RefreshScopeKind = "agent" + RefreshScopeWorkspace RefreshScopeKind = "workspace" + RefreshScopeProject RefreshScopeKind = "project" + RefreshScopeSession RefreshScopeKind = "session" + RefreshScopeGlobal RefreshScopeKind = "global" +) + +type RefreshScope struct { + Kind RefreshScopeKind `json:"kind"` + Agent string `json:"agent,omitempty"` + Org string `json:"org,omitempty"` + Project string `json:"project,omitempty"` + Workspace string `json:"workspace,omitempty"` + SessionID string `json:"session_id,omitempty"` + Reason string `json:"reason,omitempty"` + Warm bool `json:"warm,omitempty"` +} + +type RefreshResult struct { + Scope RefreshScope `json:"scope"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + Warmed bool `json:"warmed,omitempty"` + RefreshedAt string `json:"refreshed_at"` +} + +type SessionResolver interface { + GetSession(id string) (*state.Session, bool) +} + +type HotReloadCoordinator struct { + pool *RuntimePool + sessions SessionResolver +} + +func NewHotReloadCoordinator(pool *RuntimePool, sessions SessionResolver) *HotReloadCoordinator { + return &HotReloadCoordinator{pool: pool, sessions: sessions} +} + +func (c *HotReloadCoordinator) Refresh(ctx context.Context, scope RefreshScope) RefreshResult { + result := RefreshResult{ + Scope: normalizeRefreshScope(scope), + Status: "refreshed", + RefreshedAt: time.Now().UTC().Format(time.RFC3339), + } + if c == nil || c.pool == nil { + return failRefreshResult(result, "runtime pool is not configured") + } + select { + case <-ctx.Done(): + return failRefreshResult(result, ctx.Err().Error()) + default: + } + + switch result.Scope.Kind { + case RefreshScopeRuntime: + if strings.TrimSpace(result.Scope.Workspace) == "" { + return failRefreshResult(result, "workspace is required for runtime refresh") + } + c.pool.Refresh(result.Scope.Agent, result.Scope.Org, result.Scope.Project, result.Scope.Workspace) + case RefreshScopeAgent: + if strings.TrimSpace(result.Scope.Agent) == "" { + return failRefreshResult(result, "agent is required for agent refresh") + } + c.pool.RefreshByAgent(result.Scope.Agent) + case RefreshScopeWorkspace: + if strings.TrimSpace(result.Scope.Workspace) == "" { + return failRefreshResult(result, "workspace is required for workspace refresh") + } + c.pool.RefreshByWorkspace(result.Scope.Workspace) + case RefreshScopeProject: + if strings.TrimSpace(result.Scope.Project) == "" { + return failRefreshResult(result, "project is required for project refresh") + } + c.pool.RefreshByProject(result.Scope.Project) + case RefreshScopeSession: + binding, err := c.sessionBinding(result.Scope.SessionID) + if err != nil { + return failRefreshResult(result, err.Error()) + } + result.Scope.Agent = firstNonEmpty(result.Scope.Agent, binding.Agent) + result.Scope.Org = firstNonEmpty(result.Scope.Org, binding.Org) + result.Scope.Project = firstNonEmpty(result.Scope.Project, binding.Project) + result.Scope.Workspace = firstNonEmpty(result.Scope.Workspace, binding.Workspace) + if strings.TrimSpace(result.Scope.Workspace) == "" { + return failRefreshResult(result, "workspace is required for session refresh") + } + c.pool.Refresh(result.Scope.Agent, result.Scope.Org, result.Scope.Project, result.Scope.Workspace) + case RefreshScopeGlobal: + c.pool.RefreshAll() + default: + return failRefreshResult(result, fmt.Sprintf("unsupported refresh scope: %s", result.Scope.Kind)) + } + + if result.Scope.Warm && result.Scope.Kind == RefreshScopeRuntime { + if _, err := c.pool.GetOrCreate(result.Scope.Agent, result.Scope.Org, result.Scope.Project, result.Scope.Workspace); err != nil { + return failRefreshResult(result, err.Error()) + } + result.Warmed = true + } + return result +} + +func (c *HotReloadCoordinator) RefreshMany(ctx context.Context, scopes []RefreshScope) []RefreshResult { + results := make([]RefreshResult, 0, len(scopes)) + for _, scope := range scopes { + results = append(results, c.Refresh(ctx, scope)) + } + return results +} + +func (c *HotReloadCoordinator) sessionBinding(sessionID string) (state.SessionExecutionBinding, error) { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return state.SessionExecutionBinding{}, fmt.Errorf("session_id is required for session refresh") + } + if c == nil || c.sessions == nil { + return state.SessionExecutionBinding{}, fmt.Errorf("session resolver is not configured") + } + session, ok := c.sessions.GetSession(sessionID) + if !ok { + return state.SessionExecutionBinding{}, fmt.Errorf("session not found: %s", sessionID) + } + return state.SessionExecutionBindingValue(session), nil +} + +func normalizeRefreshScope(scope RefreshScope) RefreshScope { + scope.Kind = RefreshScopeKind(strings.ToLower(strings.TrimSpace(string(scope.Kind)))) + if scope.Kind == "" { + scope.Kind = RefreshScopeRuntime + } + scope.Agent = strings.TrimSpace(scope.Agent) + scope.Org = strings.TrimSpace(scope.Org) + scope.Project = strings.TrimSpace(scope.Project) + scope.Workspace = strings.TrimSpace(scope.Workspace) + scope.SessionID = strings.TrimSpace(scope.SessionID) + scope.Reason = strings.TrimSpace(scope.Reason) + return scope +} + +func failRefreshResult(result RefreshResult, msg string) RefreshResult { + result.Status = "failed" + result.Error = msg + return result +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/pkg/runtime/hot_reload_test.go b/pkg/runtime/hot_reload_test.go new file mode 100644 index 00000000..e3dd46ff --- /dev/null +++ b/pkg/runtime/hot_reload_test.go @@ -0,0 +1,81 @@ +package runtime + +import ( + "context" + "testing" + "time" + + "github.com/1024XEngineer/anyclaw/pkg/config" + "github.com/1024XEngineer/anyclaw/pkg/state" +) + +func TestHotReloadCoordinatorRefreshesScopedRuntime(t *testing.T) { + store, _ := newRuntimePoolTestStore(t) + pool := NewRuntimePool("anyclaw.json", store, 4, time.Hour) + pool.Remember("agent-1", "org-1", "project-1", "workspace-1", &MainRuntime{Config: &config.Config{Agent: config.AgentConfig{Name: "agent-1"}}}) + pool.Remember("agent-2", "org-1", "project-2", "workspace-2", &MainRuntime{Config: &config.Config{Agent: config.AgentConfig{Name: "agent-2"}}}) + + result := NewHotReloadCoordinator(pool, store).Refresh(context.Background(), RefreshScope{ + Kind: RefreshScopeRuntime, + Agent: "agent-1", + Org: "org-1", + Project: "project-1", + Workspace: "workspace-1", + }) + if result.Status != "refreshed" { + t.Fatalf("result = %#v, want refreshed", result) + } + if _, ok := pool.runtimes[runtimeKey("agent-1", "org-1", "project-1", "workspace-1")]; ok { + t.Fatal("expected scoped runtime to be refreshed") + } + if _, ok := pool.runtimes[runtimeKey("agent-2", "org-1", "project-2", "workspace-2")]; !ok { + t.Fatal("other runtime should remain pooled") + } + if metrics := pool.Metrics(); metrics.Refreshes != 1 { + t.Fatalf("metrics = %+v, want one refresh", metrics) + } +} + +func TestHotReloadCoordinatorSessionScopeUsesExecutionBinding(t *testing.T) { + store, sessions := newRuntimePoolTestStore(t) + pool := NewRuntimePool("anyclaw.json", store, 4, time.Hour) + session, err := sessions.CreateWithOptions(state.SessionCreateOptions{ + Title: "session", + AgentName: "agent-1", + Org: "org-1", + Project: "project-1", + Workspace: "workspace-1", + }) + if err != nil { + t.Fatal(err) + } + pool.Remember("agent-1", "org-1", "project-1", "workspace-1", &MainRuntime{Config: &config.Config{Agent: config.AgentConfig{Name: "agent-1"}}}) + + result := NewHotReloadCoordinator(pool, store).Refresh(context.Background(), RefreshScope{ + Kind: RefreshScopeSession, + SessionID: session.ID, + }) + if result.Status != "refreshed" || result.Scope.Workspace != "workspace-1" || result.Scope.Agent != "agent-1" { + t.Fatalf("result = %#v, want session binding", result) + } + if _, ok := pool.runtimes[runtimeKey("agent-1", "org-1", "project-1", "workspace-1")]; ok { + t.Fatal("expected session runtime to be refreshed") + } +} + +func TestHotReloadCoordinatorIsolatesFailures(t *testing.T) { + store, _ := newRuntimePoolTestStore(t) + pool := NewRuntimePool("anyclaw.json", store, 4, time.Hour) + pool.Remember("agent-1", "org-1", "project-1", "workspace-1", &MainRuntime{Config: &config.Config{Agent: config.AgentConfig{Name: "agent-1"}}}) + + results := NewHotReloadCoordinator(pool, store).RefreshMany(context.Background(), []RefreshScope{ + {Kind: RefreshScopeRuntime, Agent: "agent-1", Org: "org-1", Project: "project-1", Workspace: "workspace-1"}, + {Kind: RefreshScopeRuntime, Agent: "agent-2", Org: "org-1", Project: "project-2"}, + }) + if len(results) != 2 || results[0].Status != "refreshed" || results[1].Status != "failed" { + t.Fatalf("results = %#v, want isolated success/failure", results) + } + if metrics := pool.Metrics(); metrics.Refreshes != 1 { + t.Fatalf("metrics = %+v, want only successful scope counted", metrics) + } +} diff --git a/pkg/runtime/market_integration.go b/pkg/runtime/market_integration.go new file mode 100644 index 00000000..6c1c44c9 --- /dev/null +++ b/pkg/runtime/market_integration.go @@ -0,0 +1,340 @@ +package runtime + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/1024XEngineer/anyclaw/pkg/capability/skills" + "github.com/1024XEngineer/anyclaw/pkg/config" + "github.com/1024XEngineer/anyclaw/pkg/marketplace" +) + +type runtimeMarketArtifactManifest struct { + ID string `json:"id"` + Kind string `json:"kind"` + Name string `json:"name"` + Summary string `json:"summary,omitempty"` + Description string `json:"description,omitempty"` + DescriptionMD string `json:"description_md,omitempty"` + Version string `json:"version"` + Publisher string `json:"publisher,omitempty"` + Permissions []string `json:"permissions,omitempty"` + Tags []string `json:"tags,omitempty"` + ManifestSummary map[string]string `json:"manifest_summary,omitempty"` +} + +func (a *MainRuntime) IntegrateMarketReceiptAndRefresh(ctx context.Context, receipt *marketplace.InstallReceipt) error { + if a == nil || a.Config == nil { + return fmt.Errorf("runtime config is unavailable") + } + if receipt == nil { + return fmt.Errorf("install receipt is nil") + } + manifest := readRuntimeMarketArtifactManifest(receipt.InstalledPath) + switch receipt.Kind { + case marketplace.ArtifactKindSkill: + if err := a.integrateRuntimeMarketSkill(receipt, manifest); err != nil { + return err + } + case marketplace.ArtifactKindAgent: + if err := a.integrateRuntimeMarketAgent(receipt, manifest); err != nil { + return err + } + case marketplace.ArtifactKindCLI: + if err := a.integrateRuntimeMarketCLI(receipt, manifest); err != nil { + return err + } + default: + return fmt.Errorf("unsupported marketplace artifact kind: %s", receipt.Kind) + } + return a.RefreshToolRegistry() +} + +func (a *MainRuntime) RefreshAfterMarketBinding(ctx context.Context, binding *marketplace.Binding) error { + if a == nil { + return fmt.Errorf("runtime is unavailable") + } + return a.RefreshToolRegistry() +} + +func (a *MainRuntime) integrateRuntimeMarketSkill(receipt *marketplace.InstallReceipt, manifest runtimeMarketArtifactManifest) error { + skillName := firstNonEmptyRuntimeMarket(manifest.Name, receipt.Name, receipt.ArtifactID) + skillDirName := safeRuntimeMarketName(firstNonEmptyRuntimeMarket(receipt.ArtifactID, skillName)) + skillsDir := config.ResolvePath(a.ConfigPath, a.Config.Skills.Dir) + if skillsDir == "" { + skillsDir = config.ResolvePath(a.ConfigPath, "skills") + } + targetDir := filepath.Join(skillsDir, skillDirName) + if err := os.MkdirAll(targetDir, 0o755); err != nil { + return err + } + sourceSkillDir := filepath.Join(receipt.InstalledPath, "skill") + if hasRuntimeMarketFile(sourceSkillDir, "skill.json") || hasRuntimeMarketFile(sourceSkillDir, "SKILL.md") { + if err := copyRuntimeMarketDirContents(sourceSkillDir, targetDir); err != nil { + return err + } + } + if !hasRuntimeMarketFile(targetDir, "skill.json") { + if err := writeRuntimeMarketSkillJSON(targetDir, receipt, manifest, skillName); err != nil { + return err + } + } + return a.attachRuntimeMarketSkill(skillName, receipt.Version, receipt.Permissions) +} + +func (a *MainRuntime) integrateRuntimeMarketAgent(receipt *marketplace.InstallReceipt, manifest runtimeMarketArtifactManifest) error { + agentName := firstNonEmptyRuntimeMarket(manifest.Name, receipt.Name, receipt.ArtifactID) + profile := config.AgentProfile{ + Name: agentName, + Description: firstNonEmptyRuntimeMarket(manifest.Summary, manifest.Description, receipt.Description), + Role: "marketplace", + Persona: firstNonEmptyRuntimeMarket(manifest.DescriptionMD, manifest.Description, manifest.Summary, receipt.Description), + Domain: "marketplace", + Expertise: append([]string(nil), manifest.Tags...), + WorkingDir: a.Config.Agent.WorkingDir, + PermissionLevel: firstNonEmptyRuntimeMarket(runtimeMarketPermissionLevelFrom(receipt.Permissions), a.Config.Agent.PermissionLevel, "limited"), + ProviderRef: a.Config.LLM.DefaultProviderRef, + Enabled: config.BoolPtr(true), + } + if strings.TrimSpace(profile.Persona) == "" { + profile.Persona = "Marketplace installed agent: " + receipt.ArtifactID + } + profile.SystemPrompt = profile.Persona + if err := a.Config.UpsertAgentProfile(profile); err != nil { + return err + } + return a.Config.Save(a.ConfigPath) +} + +func (a *MainRuntime) integrateRuntimeMarketCLI(receipt *marketplace.InstallReceipt, manifest runtimeMarketArtifactManifest) error { + root := filepath.Join(a.WorkingDir, "CLI-Anything") + if err := os.MkdirAll(root, 0o755); err != nil { + return err + } + entryName := safeRuntimeMarketName(firstNonEmptyRuntimeMarket(manifest.ManifestSummary["command"], manifest.Name, receipt.Name, receipt.ArtifactID)) + commandDir := filepath.Join(root, "bin") + if err := os.MkdirAll(commandDir, 0o755); err != nil { + return err + } + commandPath := filepath.Join(commandDir, entryName+".cmd") + entry := map[string]any{ + "name": entryName, + "display_name": firstNonEmptyRuntimeMarket(manifest.Name, receipt.Name, entryName), + "version": firstNonEmptyRuntimeMarket(receipt.Version, manifest.Version), + "description": firstNonEmptyRuntimeMarket(manifest.Summary, manifest.Description, receipt.Description), + "entry_point": commandPath, + "category": "marketplace", + "contributor": firstNonEmptyRuntimeMarket(manifest.Publisher, "AnyClaw Cloud"), + } + if err := upsertRuntimeMarketCLIRegistryEntry(filepath.Join(root, "registry.json"), entryName, entry); err != nil { + return err + } + return os.WriteFile(commandPath, []byte("@echo off\r\nrem AnyClaw marketplace CLI placeholder\r\n"), 0o755) +} + +func (a *MainRuntime) attachRuntimeMarketSkill(name, version string, permissions []string) error { + if strings.TrimSpace(name) == "" { + return nil + } + if profile, ok := a.Config.ResolveMainAgentProfile(); ok { + found := false + for i := range profile.Skills { + if strings.EqualFold(strings.TrimSpace(profile.Skills[i].Name), strings.TrimSpace(name)) { + profile.Skills[i].Enabled = true + if strings.TrimSpace(profile.Skills[i].Version) == "" { + profile.Skills[i].Version = version + } + found = true + break + } + } + if !found { + profile.Skills = append(profile.Skills, config.AgentSkillRef{Name: name, Enabled: true, Version: version, Permissions: append([]string(nil), permissions...)}) + } + if err := a.Config.UpsertAgentProfile(profile); err != nil { + return err + } + return a.Config.Save(a.ConfigPath) + } + for i := range a.Config.Agent.Skills { + if strings.EqualFold(strings.TrimSpace(a.Config.Agent.Skills[i].Name), strings.TrimSpace(name)) { + a.Config.Agent.Skills[i].Enabled = true + return a.Config.Save(a.ConfigPath) + } + } + a.Config.Agent.Skills = append(a.Config.Agent.Skills, config.AgentSkillRef{Name: name, Enabled: true, Version: version, Permissions: append([]string(nil), permissions...)}) + return a.Config.Save(a.ConfigPath) +} + +func readRuntimeMarketArtifactManifest(root string) runtimeMarketArtifactManifest { + var manifest runtimeMarketArtifactManifest + data, err := os.ReadFile(filepath.Join(root, "anyclaw.artifact.json")) + if err != nil { + return manifest + } + _ = json.Unmarshal(data, &manifest) + return manifest +} + +func writeRuntimeMarketSkillJSON(targetDir string, receipt *marketplace.InstallReceipt, manifest runtimeMarketArtifactManifest, skillName string) error { + prompt := firstNonEmptyRuntimeMarket(manifest.DescriptionMD, manifest.Description, manifest.Summary, receipt.Description, "Marketplace installed skill: "+receipt.ArtifactID) + payload := map[string]any{ + "name": skillName, + "description": firstNonEmptyRuntimeMarket(manifest.Summary, manifest.Description, receipt.Description), + "version": firstNonEmptyRuntimeMarket(receipt.Version, manifest.Version, "1.0.0"), + "permissions": receipt.Permissions, + "source": "marketplace", + "registry": receipt.SourceID, + "prompts": map[string]string{"system": prompt}, + "metadata": map[string]string{ + "artifact_id": receipt.ArtifactID, + "receipt_id": receipt.ID, + "source": "marketplace", + }, + } + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(filepath.Join(targetDir, "skill.json"), data, 0o644) +} + +func reloadRuntimeSkills(cfg *config.Config) (*skills.SkillsManager, []string, error) { + if cfg == nil { + return nil, nil, fmt.Errorf("runtime config is unavailable") + } + manager := skills.NewSkillsManager(cfg.Skills.Dir) + if err := manager.Load(); err != nil && !os.IsNotExist(err) { + return nil, nil, err + } + configured := configuredAgentSkillNames(cfg) + if len(configured) == 0 { + return manager, nil, nil + } + filtered, missing := filterConfiguredSkills(manager, configured) + return filtered, missing, nil +} + +func upsertRuntimeMarketCLIRegistryEntry(path, name string, entry map[string]any) error { + var registry struct { + Meta map[string]string `json:"meta"` + CLIs []map[string]any `json:"clis"` + } + if data, err := os.ReadFile(path); err == nil { + _ = json.Unmarshal(data, ®istry) + } + if registry.Meta == nil { + registry.Meta = map[string]string{"repo": "AnyClaw Marketplace", "description": "AnyClaw marketplace CLI entries"} + } + replaced := false + for i := range registry.CLIs { + if strings.EqualFold(fmt.Sprint(registry.CLIs[i]["name"]), name) { + registry.CLIs[i] = entry + replaced = true + break + } + } + if !replaced { + registry.CLIs = append(registry.CLIs, entry) + } + data, err := json.MarshalIndent(registry, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +func copyRuntimeMarketDirContents(srcDir, destDir string) error { + srcDir = filepath.Clean(srcDir) + destDir = filepath.Clean(destDir) + return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("symlinks are not supported: %s", path) + } + rel, err := filepath.Rel(srcDir, path) + if err != nil || rel == "." { + return err + } + target := filepath.Join(destDir, rel) + if !pathWithinRuntimeMarketBase(destDir, target) { + return fmt.Errorf("copied path escapes destination: %s", rel) + } + if info.IsDir() { + return os.MkdirAll(target, 0o755) + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + return os.WriteFile(target, data, info.Mode().Perm()) + }) +} + +func hasRuntimeMarketFile(dir, name string) bool { + info, err := os.Stat(filepath.Join(dir, name)) + return err == nil && !info.IsDir() +} + +func runtimeMarketPermissionLevelFrom(perms []string) string { + for _, perm := range perms { + if strings.Contains(strings.ToLower(strings.TrimSpace(perm)), "exec") { + return "limited" + } + } + return "read-only" +} + +func safeRuntimeMarketName(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + var b strings.Builder + lastDash := false + for _, r := range value { + ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') + if ok { + b.WriteRune(r) + lastDash = false + continue + } + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + out := strings.Trim(b.String(), "-") + if out == "" { + return "market-artifact" + } + return out +} + +func pathWithinRuntimeMarketBase(base, target string) bool { + rel, err := filepath.Rel(filepath.Clean(base), filepath.Clean(target)) + if err != nil { + return false + } + return rel == "." || (!strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..") +} + +func firstNonEmptyRuntimeMarket(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/pkg/runtime/marketplace_store.go b/pkg/runtime/marketplace_store.go new file mode 100644 index 00000000..de429688 --- /dev/null +++ b/pkg/runtime/marketplace_store.go @@ -0,0 +1,13 @@ +package runtime + +import "strings" + +func marketplaceStoreRoot(workDir, workingDir string) string { + if root := strings.TrimSpace(workDir); root != "" { + return root + } + if root := strings.TrimSpace(workingDir); root != "" { + return root + } + return "." +} diff --git a/pkg/runtime/pool.go b/pkg/runtime/pool.go index f8f5cd15..e91523c8 100644 --- a/pkg/runtime/pool.go +++ b/pkg/runtime/pool.go @@ -143,6 +143,34 @@ func (p *RuntimePool) Refresh(agentName string, org string, project string, work p.Invalidate(agentName, org, project, workspaceID) } +func (p *RuntimePool) RefreshByAgent(agentName string) { + p.mu.Lock() + p.metrics.Refreshes++ + p.mu.Unlock() + p.InvalidateByAgent(agentName) +} + +func (p *RuntimePool) RefreshByWorkspace(workspaceID string) { + p.mu.Lock() + p.metrics.Refreshes++ + p.mu.Unlock() + p.InvalidateByWorkspace(workspaceID) +} + +func (p *RuntimePool) RefreshByProject(projectID string) { + p.mu.Lock() + p.metrics.Refreshes++ + p.mu.Unlock() + p.InvalidateByProject(projectID) +} + +func (p *RuntimePool) RefreshAll() { + p.mu.Lock() + p.metrics.Refreshes++ + p.mu.Unlock() + p.InvalidateAll() +} + func (p *RuntimePool) Metrics() RuntimeMetrics { p.mu.Lock() defer p.mu.Unlock() diff --git a/pkg/runtime/tools_refresh.go b/pkg/runtime/tools_refresh.go index 20587197..275082fd 100644 --- a/pkg/runtime/tools_refresh.go +++ b/pkg/runtime/tools_refresh.go @@ -5,8 +5,10 @@ import ( "path/filepath" "strings" + "github.com/1024XEngineer/anyclaw/pkg/capability/markettools" "github.com/1024XEngineer/anyclaw/pkg/capability/skills" "github.com/1024XEngineer/anyclaw/pkg/capability/tools" + "github.com/1024XEngineer/anyclaw/pkg/marketplace" "github.com/1024XEngineer/anyclaw/pkg/state/memory" ) @@ -34,6 +36,15 @@ func (a *MainRuntime) RefreshToolRegistry() error { } memoryWorkDir := memory.CodexMemoryWorkspaceDir(a.WorkDir, workingDir) + refreshedSkills, _, err := reloadRuntimeSkills(a.Config) + if err != nil { + return fmt.Errorf("reload skills: %w", err) + } + a.Skills = refreshedSkills + if a.Agent != nil { + a.Agent.SetSkills(refreshedSkills) + } + registry := tools.NewRegistry() sandboxManager := tools.NewSandboxManager(a.Config.Sandbox, workingDir) permissionOptions := tools.PermissionOptions{ @@ -92,6 +103,14 @@ func (a *MainRuntime) RefreshToolRegistry() error { QMDClient: qmdClient, } tools.RegisterBuiltins(registry, builtinOpts) + markettools.Register(registry, markettools.Options{ + Store: marketplace.NewStore(marketplaceStoreRoot(a.WorkDir, workingDir)), + Registry: marketplaceRegistryClient(a.Config.Marketplace), + AutoInstallSkill: a.Config.Marketplace.AutoInstallSkill, + AuditLogger: auditLogger, + AfterInstall: a.IntegrateMarketReceiptAndRefresh, + AfterBind: a.RefreshAfterMarketBinding, + }) if a.Skills != nil { a.Skills.RegisterTools(registry, skills.ExecutionOptions{AllowExec: a.Config.Plugins.AllowExec, ExecTimeoutSeconds: a.Config.Plugins.ExecTimeoutSeconds}) } diff --git a/pkg/runtime/tools_refresh_test.go b/pkg/runtime/tools_refresh_test.go index d53d5b5a..4eac5541 100644 --- a/pkg/runtime/tools_refresh_test.go +++ b/pkg/runtime/tools_refresh_test.go @@ -1,7 +1,14 @@ package runtime import ( + "archive/zip" + "bytes" "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" "path/filepath" "strings" "testing" @@ -11,6 +18,7 @@ import ( "github.com/1024XEngineer/anyclaw/pkg/capability/skills" "github.com/1024XEngineer/anyclaw/pkg/capability/tools" "github.com/1024XEngineer/anyclaw/pkg/config" + "github.com/1024XEngineer/anyclaw/pkg/marketplace" "github.com/1024XEngineer/anyclaw/pkg/state/memory" ) @@ -96,6 +104,99 @@ func TestRefreshToolRegistrySynchronizesMainAgentTools(t *testing.T) { } } +func TestMarketInstallToolIntegratesSkillAndRefreshesRuntime(t *testing.T) { + tempDir := t.TempDir() + archive := runtimeMarketArchive(t, "cloud.skill.release-notes", marketplace.ArtifactKindSkill, "1.0.0") + server := runtimeMarketRegistryServer(t, "cloud.skill.release-notes", "skill", archive) + defer server.Close() + + cfg := config.DefaultConfig() + cfg.Agent.WorkDir = filepath.Join(tempDir, ".anyclaw") + cfg.Agent.WorkingDir = tempDir + cfg.Skills.Dir = filepath.Join(tempDir, "skills") + cfg.Plugins.Dir = filepath.Join(tempDir, "plugins") + cfg.Security.AuditLog = filepath.Join(tempDir, "audit.jsonl") + cfg.Marketplace.RegistryEndpoint = server.URL + cfg.Marketplace.AutoInstallSkill = true + cfg.Marketplace.DisableRemote = false + cfg.Marketplace.ProtocolVersion = "v1" + cfg.Marketplace.RequestTimeoutSeconds = 5 + cfg.Marketplace.DownloadTimeoutSeconds = 5 + + rt, err := NewMainRuntimeFromConfig(filepath.Join(tempDir, "anyclaw.json"), cfg) + if err != nil { + t.Fatalf("NewMainRuntimeFromConfig: %v", err) + } + t.Cleanup(func() { _ = rt.Close() }) + if hasAgentTool(rt.Agent, "skill_cloud.skill.release-notes") { + t.Fatal("did not expect market skill tool before install") + } + + out, err := rt.CallTool(tools.WithToolCaller(context.Background(), tools.ToolCaller{Role: tools.ToolCallerRoleMainAgent}), "market_install_artifact", map[string]any{ + "artifact_id": "cloud.skill.release-notes", + }) + if err != nil { + t.Fatalf("market_install_artifact: %v", err) + } + if !strings.Contains(out, `"status": "installed"`) { + t.Fatalf("install output = %s, want installed", out) + } + if !hasAgentTool(rt.Agent, "skill_cloud.skill.release-notes") { + t.Fatalf("expected installed skill tool after integration refresh, tools=%#v", rt.Agent.ListTools()) + } + if !hasAgentTool(rt.Agent, "market_search_artifacts") { + t.Fatal("expected marketplace tools to remain registered after refresh") + } +} + +func TestRefreshToolRegistryUsesRuntimeWorkDirMarketplaceStore(t *testing.T) { + tempDir := t.TempDir() + workDir := filepath.Join(tempDir, ".anyclaw") + workingDir := filepath.Join(tempDir, "workspace") + cfg := config.DefaultConfig() + cfg.Agent.WorkDir = workDir + cfg.Agent.WorkingDir = workingDir + cfg.Skills.Dir = filepath.Join(tempDir, "skills") + cfg.Plugins.Dir = filepath.Join(tempDir, "plugins") + + store := marketplace.NewStore(workDir) + if err := store.SaveReceipt(&marketplace.InstallReceipt{ + ID: "cloud.skill.release-notes@1.0.0", + ArtifactID: "cloud.skill.release-notes", + Kind: marketplace.ArtifactKindSkill, + Name: "Release Notes", + Version: "1.0.0", + Source: marketplace.SourceCloud, + InstalledPath: filepath.Join(workDir, "installed"), + InstalledBy: "user", + InstalledAt: "2026-05-07T00:00:00Z", + }); err != nil { + t.Fatal(err) + } + + rt := &MainRuntime{ + ConfigPath: filepath.Join(tempDir, "anyclaw.json"), + Config: cfg, + Skills: skills.NewSkillsManager(cfg.Skills.Dir), + WorkDir: workDir, + WorkingDir: workingDir, + } + if err := rt.RefreshToolRegistry(); err != nil { + t.Fatalf("RefreshToolRegistry: %v", err) + } + out, err := rt.CallTool(tools.WithToolCaller(context.Background(), tools.ToolCaller{Role: tools.ToolCallerRoleMainAgent}), "market_search_artifacts", map[string]any{ + "query": "release notes", + "kind": "skill", + "source": "local", + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "cloud.skill.release-notes") { + t.Fatalf("expected refreshed marketplace tool to read WorkDir store, got %s", out) + } +} + type refreshToolLLM struct { toolName string calls int @@ -141,3 +242,80 @@ func hasAgentTool(ag *agent.Agent, name string) bool { } return false } + +func runtimeMarketRegistryServer(t *testing.T, id, kind string, archive []byte) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/resolve"): + writeRuntimeMarketJSON(t, w, map[string]any{"data": map[string]any{ + "artifact_id": id, + "version": "1.0.0", + "download_url": "http://" + r.Host + "/v1/download/" + id + "/1.0.0", + "checksum_sha256": runtimeMarketSHA256(archive), + "size_bytes": len(archive), + "risk_level": "low", + "trust_level": "verified", + "permissions": []string{"fs.read"}, + "kind": kind, + "name": id, + }}) + case strings.Contains(r.URL.Path, "/v1/download/"): + _, _ = w.Write(archive) + default: + http.NotFound(w, r) + } + })) +} + +func runtimeMarketArchive(t *testing.T, id string, kind marketplace.ArtifactKind, version string) []byte { + t.Helper() + var buf bytes.Buffer + writer := zip.NewWriter(&buf) + writeZipJSONRuntimeMarket(t, writer, "anyclaw.artifact.json", map[string]any{ + "id": id, + "kind": string(kind), + "name": id, + "summary": "Release notes helper", + "description": "Draft release notes.", + "version": version, + }) + writeZipTextRuntimeMarket(t, writer, "skill/SKILL.md", "# Release Notes\n\nDraft release notes.\n") + if err := writer.Close(); err != nil { + t.Fatalf("close zip: %v", err) + } + return buf.Bytes() +} + +func writeZipJSONRuntimeMarket(t *testing.T, writer *zip.Writer, name string, value any) { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal zip json: %v", err) + } + writeZipTextRuntimeMarket(t, writer, name, string(data)) +} + +func writeZipTextRuntimeMarket(t *testing.T, writer *zip.Writer, name string, value string) { + t.Helper() + file, err := writer.Create(name) + if err != nil { + t.Fatalf("create zip entry %s: %v", name, err) + } + if _, err := file.Write([]byte(value)); err != nil { + t.Fatalf("write zip entry %s: %v", name, err) + } +} + +func writeRuntimeMarketJSON(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.Fatalf("encode json: %v", err) + } +} + +func runtimeMarketSHA256(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} From a743686bef0d21f5808951f5bc251e5a1462354f Mon Sep 17 00:00:00 2001 From: h1177h <2928932863@qq.com> Date: Fri, 8 May 2026 21:26:57 +0800 Subject: [PATCH 2/2] fix: target marketplace refresh and cli integration --- pkg/runtime/app.go | 1 + pkg/runtime/market_integration.go | 79 ++++++++++++++++++-- pkg/runtime/tools_refresh_test.go | 115 ++++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 7 deletions(-) diff --git a/pkg/runtime/app.go b/pkg/runtime/app.go index 8c93808e..6dd26c4a 100644 --- a/pkg/runtime/app.go +++ b/pkg/runtime/app.go @@ -81,6 +81,7 @@ type MainRuntime struct { SecretsStore *secrets.Store WorkDir string WorkingDir string + HotReload *HotReloadCoordinator } // App is kept as a legacy alias while callers migrate to MainRuntime naming. diff --git a/pkg/runtime/market_integration.go b/pkg/runtime/market_integration.go index 6c1c44c9..72aafa64 100644 --- a/pkg/runtime/market_integration.go +++ b/pkg/runtime/market_integration.go @@ -58,7 +58,24 @@ func (a *MainRuntime) RefreshAfterMarketBinding(ctx context.Context, binding *ma if a == nil { return fmt.Errorf("runtime is unavailable") } - return a.RefreshToolRegistry() + if err := a.RefreshToolRegistry(); err != nil { + return err + } + if binding == nil { + return nil + } + scope := refreshScopeForMarketBinding(binding) + if scope.Kind == "" { + return nil + } + if a.HotReload == nil { + return nil + } + result := a.HotReload.Refresh(ctx, scope) + if result.Status == "failed" { + return fmt.Errorf("refresh after marketplace binding: %s", result.Error) + } + return nil } func (a *MainRuntime) integrateRuntimeMarketSkill(receipt *marketplace.InstallReceipt, manifest runtimeMarketArtifactManifest) error { @@ -115,25 +132,73 @@ func (a *MainRuntime) integrateRuntimeMarketCLI(receipt *marketplace.InstallRece if err := os.MkdirAll(root, 0o755); err != nil { return err } - entryName := safeRuntimeMarketName(firstNonEmptyRuntimeMarket(manifest.ManifestSummary["command"], manifest.Name, receipt.Name, receipt.ArtifactID)) - commandDir := filepath.Join(root, "bin") - if err := os.MkdirAll(commandDir, 0o755); err != nil { + spec, err := runtimeMarketCLISpec(receipt.InstalledPath, receipt, manifest) + if err != nil { return err } - commandPath := filepath.Join(commandDir, entryName+".cmd") + entryName := safeRuntimeMarketName(firstNonEmptyRuntimeMarket(spec.Name, manifest.ManifestSummary["command"], manifest.Name, receipt.Name, receipt.ArtifactID)) + entryPoint := filepath.Join(receipt.InstalledPath, filepath.FromSlash(spec.EntryPoint)) + if !pathWithinRuntimeMarketBase(receipt.InstalledPath, entryPoint) { + return fmt.Errorf("marketplace CLI entry point escapes installed path: %s", spec.EntryPoint) + } + info, err := os.Stat(entryPoint) + if err != nil { + return fmt.Errorf("marketplace CLI entry point missing: %w", err) + } + if info.IsDir() { + return fmt.Errorf("marketplace CLI entry point is a directory: %s", spec.EntryPoint) + } entry := map[string]any{ "name": entryName, "display_name": firstNonEmptyRuntimeMarket(manifest.Name, receipt.Name, entryName), "version": firstNonEmptyRuntimeMarket(receipt.Version, manifest.Version), "description": firstNonEmptyRuntimeMarket(manifest.Summary, manifest.Description, receipt.Description), - "entry_point": commandPath, + "entry_point": entryPoint, "category": "marketplace", "contributor": firstNonEmptyRuntimeMarket(manifest.Publisher, "AnyClaw Cloud"), } if err := upsertRuntimeMarketCLIRegistryEntry(filepath.Join(root, "registry.json"), entryName, entry); err != nil { return err } - return os.WriteFile(commandPath, []byte("@echo off\r\nrem AnyClaw marketplace CLI placeholder\r\n"), 0o755) + return nil +} + +type runtimeMarketCLISpecData struct { + Name string `json:"name"` + EntryPoint string `json:"entry_point"` + Command string `json:"command"` +} + +func runtimeMarketCLISpec(installedPath string, receipt *marketplace.InstallReceipt, manifest runtimeMarketArtifactManifest) (runtimeMarketCLISpecData, error) { + var spec runtimeMarketCLISpecData + if data, err := os.ReadFile(filepath.Join(installedPath, "cli", "command.json")); err == nil { + _ = json.Unmarshal(data, &spec) + } + spec.Name = firstNonEmptyRuntimeMarket(spec.Name, manifest.ManifestSummary["command"], manifest.Name, receipt.Name, receipt.ArtifactID) + spec.EntryPoint = firstNonEmptyRuntimeMarket(spec.EntryPoint, spec.Command, manifest.ManifestSummary["entry_point"], manifest.ManifestSummary["command"]) + if strings.TrimSpace(spec.EntryPoint) == "" { + return spec, fmt.Errorf("marketplace CLI artifact requires cli/command.json entry_point") + } + return spec, nil +} + +func refreshScopeForMarketBinding(binding *marketplace.Binding) RefreshScope { + targetID := strings.TrimSpace(binding.TargetID) + scope := RefreshScope{Reason: "marketplace binding " + binding.ID} + switch binding.TargetType { + case marketplace.TargetMainAgent: + scope.Kind = RefreshScopeAgent + scope.Agent = firstNonEmptyRuntimeMarket(targetID, binding.TargetName) + case marketplace.TargetPersistentSubagent: + scope.Kind = RefreshScopeAgent + scope.Agent = targetID + case marketplace.TargetWorkspace: + scope.Kind = RefreshScopeWorkspace + scope.Workspace = targetID + case marketplace.TargetRuntimeGlobal: + scope.Kind = RefreshScopeGlobal + } + return scope } func (a *MainRuntime) attachRuntimeMarketSkill(name, version string, permissions []string) error { diff --git a/pkg/runtime/tools_refresh_test.go b/pkg/runtime/tools_refresh_test.go index 4eac5541..d457eda9 100644 --- a/pkg/runtime/tools_refresh_test.go +++ b/pkg/runtime/tools_refresh_test.go @@ -9,6 +9,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "testing" @@ -197,6 +198,106 @@ func TestRefreshToolRegistryUsesRuntimeWorkDirMarketplaceStore(t *testing.T) { } } +func TestRefreshAfterMarketBindingUsesTargetScope(t *testing.T) { + store, _ := newRuntimePoolTestStore(t) + pool := NewRuntimePool("anyclaw.json", store, 4, 0) + pool.Remember("agent-1", "org-1", "project-1", "workspace-1", &MainRuntime{}) + pool.Remember("agent-2", "org-1", "project-2", "workspace-2", &MainRuntime{}) + rt := &MainRuntime{ + Config: config.DefaultConfig(), + Tools: tools.NewRegistry(), + HotReload: NewHotReloadCoordinator(pool, store), + } + if err := rt.RefreshAfterMarketBinding(context.Background(), &marketplace.Binding{ + ID: "binding-1", + TargetType: marketplace.TargetWorkspace, + TargetID: "workspace-1", + }); err != nil { + t.Fatal(err) + } + if _, ok := pool.runtimes[runtimeKey("agent-1", "org-1", "project-1", "workspace-1")]; ok { + t.Fatal("expected workspace-1 runtime to be refreshed") + } + if _, ok := pool.runtimes[runtimeKey("agent-2", "org-1", "project-2", "workspace-2")]; !ok { + t.Fatal("workspace-2 runtime should remain pooled") + } +} + +func TestIntegrateMarketCLIUsesPackageEntryPoint(t *testing.T) { + tempDir := t.TempDir() + installed := filepath.Join(tempDir, "installed") + if err := os.MkdirAll(filepath.Join(installed, "cli", "bin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(installed, "cli", "bin", "repo-health.cmd"), []byte("@echo off\r\n"), 0o755); err != nil { + t.Fatal(err) + } + writeJSONFileRuntimeMarket(t, filepath.Join(installed, "anyclaw.artifact.json"), map[string]any{ + "id": "cloud.cli.repo-health", + "kind": "cli", + "name": "Repo Health", + "version": "1.0.0", + }) + writeJSONFileRuntimeMarket(t, filepath.Join(installed, "cli", "command.json"), map[string]any{ + "name": "repo-health", + "entry_point": "cli/bin/repo-health.cmd", + }) + rt := &MainRuntime{Config: config.DefaultConfig(), WorkingDir: filepath.Join(tempDir, "workspace")} + if err := rt.IntegrateMarketReceiptAndRefresh(context.Background(), &marketplace.InstallReceipt{ + ID: "cloud.cli.repo-health@1.0.0", + ArtifactID: "cloud.cli.repo-health", + Kind: marketplace.ArtifactKindCLI, + Name: "Repo Health", + Version: "1.0.0", + InstalledPath: installed, + }); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(rt.WorkingDir, "CLI-Anything", "registry.json")) + if err != nil { + t.Fatal(err) + } + var registry struct { + CLIs []map[string]any `json:"clis"` + } + if err := json.Unmarshal(data, ®istry); err != nil { + t.Fatal(err) + } + wantEntry := filepath.Join(installed, "cli", "bin", "repo-health.cmd") + if len(registry.CLIs) != 1 || registry.CLIs[0]["entry_point"] != wantEntry { + t.Fatalf("entry point = %#v, want %s", registry.CLIs, wantEntry) + } +} + +func TestIntegrateMarketCLIRejectsMissingEntryPoint(t *testing.T) { + tempDir := t.TempDir() + installed := filepath.Join(tempDir, "installed") + if err := os.MkdirAll(filepath.Join(installed, "cli"), 0o755); err != nil { + t.Fatal(err) + } + writeJSONFileRuntimeMarket(t, filepath.Join(installed, "anyclaw.artifact.json"), map[string]any{ + "id": "cloud.cli.repo-health", + "kind": "cli", + "name": "Repo Health", + "version": "1.0.0", + }) + writeJSONFileRuntimeMarket(t, filepath.Join(installed, "cli", "command.json"), map[string]any{ + "name": "repo-health", + "entry_point": "cli/bin/missing.cmd", + }) + err := (&MainRuntime{Config: config.DefaultConfig(), WorkingDir: filepath.Join(tempDir, "workspace")}).IntegrateMarketReceiptAndRefresh(context.Background(), &marketplace.InstallReceipt{ + ID: "cloud.cli.repo-health@1.0.0", + ArtifactID: "cloud.cli.repo-health", + Kind: marketplace.ArtifactKindCLI, + Name: "Repo Health", + Version: "1.0.0", + InstalledPath: installed, + }) + if err == nil || !strings.Contains(err.Error(), "entry point missing") { + t.Fatalf("expected missing entry point error, got %v", err) + } +} + type refreshToolLLM struct { toolName string calls int @@ -315,6 +416,20 @@ func writeRuntimeMarketJSON(t *testing.T, w http.ResponseWriter, value any) { } } +func writeJSONFileRuntimeMarket(t *testing.T, path string, value any) { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } +} + func runtimeMarketSHA256(data []byte) string { sum := sha256.Sum256(data) return hex.EncodeToString(sum[:])