diff --git a/examples/marketplace/agent-code-reviewer/anyclaw.artifact.json b/examples/marketplace/agent-code-reviewer/anyclaw.artifact.json new file mode 100644 index 00000000..ea47f996 --- /dev/null +++ b/examples/marketplace/agent-code-reviewer/anyclaw.artifact.json @@ -0,0 +1,38 @@ +{ + "artifact": { + "id": "cloud.agent.example-code-reviewer", + "kind": "agent", + "name": "Example Code Reviewer Agent", + "summary": "Reviews local changes and highlights concrete risks before merge.", + "description_md": "A minimal AnyClaw marketplace agent example used for publishing smoke tests.", + "latest_version": "1.0.0", + "source": "anyclaw-cloud", + "risk_level": "medium", + "trust_level": "verified", + "permissions": ["fs.read", "git.read"], + "compatibility": { + "anyclaw_min": "0.1.0", + "os": ["windows", "linux", "darwin"], + "arch": ["amd64", "arm64"] + }, + "tags": ["agent", "review", "quality"], + "hit_signals": ["code review", "pull request", "风险检查"], + "manifest_summary": { + "entry": "agent/profile.json" + } + }, + "versions": [ + { + "version": "1.0.0", + "released_at": "2026-05-07T00:00:00Z", + "changelog_md": "Initial example marketplace agent.", + "permissions": ["fs.read", "git.read"], + "permissions_diff": ["fs.read", "git.read"], + "compatibility": { + "anyclaw_min": "0.1.0", + "os": ["windows", "linux", "darwin"], + "arch": ["amd64", "arm64"] + } + } + ] +} diff --git a/examples/marketplace/agent-marketplace-operator/anyclaw.artifact.json b/examples/marketplace/agent-marketplace-operator/anyclaw.artifact.json new file mode 100644 index 00000000..79a187c4 --- /dev/null +++ b/examples/marketplace/agent-marketplace-operator/anyclaw.artifact.json @@ -0,0 +1,41 @@ +{ + "artifact": { + "id": "anyclaw.agent.marketplace-operator", + "kind": "agent", + "name": "Marketplace Operator", + "summary": "Plans marketplace releases, checks publish readiness, and prepares audit-friendly rollout notes.", + "description_md": "Marketplace Operator is an AnyClaw agent for running a small cloud marketplace safely. It helps maintain artifact metadata, review release notes, verify risk and permission declarations, and prepare publish or quarantine actions for an administrator to approve.", + "latest_version": "1.0.0", + "source": "anyclaw-cloud", + "publisher": "AnyClaw Labs", + "risk_level": "medium", + "trust_level": "verified", + "permissions": ["fs.read", "git.read", "network.registry"], + "compatibility": { + "anyclaw_min": "0.1.0", + "os": ["windows", "linux", "darwin"], + "arch": ["amd64", "arm64"] + }, + "tags": ["agent", "marketplace", "release", "audit"], + "hit_signals": ["publish artifact", "marketplace release", "release review", "quarantine", "audit"], + "score": 0.98, + "manifest_summary": { + "entry": "agent/profile.json", + "use_case": "marketplace operations" + } + }, + "versions": [ + { + "version": "1.0.0", + "released_at": "2026-05-07T00:00:00Z", + "changelog_md": "Initial Marketplace Operator agent for release planning, metadata review, and audit-oriented marketplace operations.", + "permissions": ["fs.read", "git.read", "network.registry"], + "permissions_diff": ["fs.read", "git.read", "network.registry"], + "compatibility": { + "anyclaw_min": "0.1.0", + "os": ["windows", "linux", "darwin"], + "arch": ["amd64", "arm64"] + } + } + ] +} diff --git a/examples/marketplace/cli-agent-native-runner/anyclaw.artifact.json b/examples/marketplace/cli-agent-native-runner/anyclaw.artifact.json new file mode 100644 index 00000000..921983f9 --- /dev/null +++ b/examples/marketplace/cli-agent-native-runner/anyclaw.artifact.json @@ -0,0 +1,41 @@ +{ + "artifact": { + "id": "anyclaw.cli.agent-native-runner", + "kind": "cli", + "name": "Agent Native Runner", + "summary": "Wraps project commands as agent-friendly CLI actions with predictable inputs, outputs, and safety checks.", + "description_md": "Agent Native Runner is an AnyClaw CLI artifact for exposing repeatable project commands to agents in a predictable way. It follows the same product direction as agent-native CLI hubs: make command-line tools discoverable, installable, and safe for agent workflows without hiding execution risk.", + "latest_version": "1.0.0", + "source": "anyclaw-cloud", + "publisher": "AnyClaw Labs", + "risk_level": "medium", + "trust_level": "verified", + "permissions": ["process.exec", "fs.read", "fs.write"], + "compatibility": { + "anyclaw_min": "0.1.0", + "os": ["windows", "linux", "darwin"], + "arch": ["amd64", "arm64"] + }, + "tags": ["cli", "agent-native", "automation", "commands", "workflow"], + "hit_signals": ["agent-native cli", "wrap command", "CLI hub", "automation command", "run tool"], + "score": 0.96, + "manifest_summary": { + "command": "anyclaw-agent-runner", + "use_case": "agent-native command wrapper" + } + }, + "versions": [ + { + "version": "1.0.0", + "released_at": "2026-05-07T00:00:00Z", + "changelog_md": "Initial Agent Native Runner CLI artifact for agent-friendly command wrappers.", + "permissions": ["process.exec", "fs.read", "fs.write"], + "permissions_diff": ["process.exec", "fs.read", "fs.write"], + "compatibility": { + "anyclaw_min": "0.1.0", + "os": ["windows", "linux", "darwin"], + "arch": ["amd64", "arm64"] + } + } + ] +} diff --git a/examples/marketplace/cli-repo-health/anyclaw.artifact.json b/examples/marketplace/cli-repo-health/anyclaw.artifact.json new file mode 100644 index 00000000..52bf1535 --- /dev/null +++ b/examples/marketplace/cli-repo-health/anyclaw.artifact.json @@ -0,0 +1,38 @@ +{ + "artifact": { + "id": "cloud.cli.example-repo-health", + "kind": "cli", + "name": "Example Repo Health CLI", + "summary": "Runs a lightweight repository health check command.", + "description_md": "A minimal AnyClaw marketplace CLI example used for publishing smoke tests.", + "latest_version": "1.0.0", + "source": "anyclaw-cloud", + "risk_level": "medium", + "trust_level": "verified", + "permissions": ["process.exec", "fs.read"], + "compatibility": { + "anyclaw_min": "0.1.0", + "os": ["windows", "linux", "darwin"], + "arch": ["amd64", "arm64"] + }, + "tags": ["cli", "health", "repository"], + "hit_signals": ["repo health", "诊断", "cli"], + "manifest_summary": { + "command": "anyclaw-repo-health" + } + }, + "versions": [ + { + "version": "1.0.0", + "released_at": "2026-05-07T00:00:00Z", + "changelog_md": "Initial example marketplace CLI.", + "permissions": ["process.exec", "fs.read"], + "permissions_diff": ["process.exec", "fs.read"], + "compatibility": { + "anyclaw_min": "0.1.0", + "os": ["windows", "linux", "darwin"], + "arch": ["amd64", "arm64"] + } + } + ] +} diff --git a/examples/marketplace/skill-release-notes/anyclaw.artifact.json b/examples/marketplace/skill-release-notes/anyclaw.artifact.json new file mode 100644 index 00000000..061ef18a --- /dev/null +++ b/examples/marketplace/skill-release-notes/anyclaw.artifact.json @@ -0,0 +1,38 @@ +{ + "artifact": { + "id": "cloud.skill.example-release-notes", + "kind": "skill", + "name": "Example Release Notes Skill", + "summary": "Turns git history and release notes into a compact changelog draft.", + "description_md": "A minimal AnyClaw marketplace skill example used to verify publisher-token based publishing.", + "latest_version": "1.0.0", + "source": "anyclaw-cloud", + "risk_level": "low", + "trust_level": "verified", + "permissions": ["fs.read", "git.read"], + "compatibility": { + "anyclaw_min": "0.1.0", + "os": ["windows", "linux", "darwin"], + "arch": ["amd64", "arm64"] + }, + "tags": ["skill", "release", "writing"], + "hit_signals": ["release notes", "changelog", "发布说明"], + "manifest_summary": { + "entry": "skill/SKILL.md" + } + }, + "versions": [ + { + "version": "1.0.0", + "released_at": "2026-05-07T00:00:00Z", + "changelog_md": "Initial example marketplace skill.", + "permissions": ["fs.read", "git.read"], + "permissions_diff": ["fs.read", "git.read"], + "compatibility": { + "anyclaw_min": "0.1.0", + "os": ["windows", "linux", "darwin"], + "arch": ["amd64", "arm64"] + } + } + ] +} diff --git a/examples/marketplace/skill-skill-author/anyclaw.artifact.json b/examples/marketplace/skill-skill-author/anyclaw.artifact.json new file mode 100644 index 00000000..e086a5fa --- /dev/null +++ b/examples/marketplace/skill-skill-author/anyclaw.artifact.json @@ -0,0 +1,41 @@ +{ + "artifact": { + "id": "anyclaw.skill.skill-author", + "kind": "skill", + "name": "Skill Author", + "summary": "Drafts high-quality AnyClaw skills with clear triggers, workflows, safety notes, and validation steps.", + "description_md": "Skill Author helps turn repeatable work into an installable AnyClaw skill. It focuses on SKILL.md structure, trigger clarity, minimal dependency footprint, examples, validation steps, and marketplace-ready metadata. It is inspired by skill-market patterns such as public skill catalogs, but uses AnyClaw's own artifact contract.", + "latest_version": "1.0.0", + "source": "anyclaw-cloud", + "publisher": "AnyClaw Labs", + "risk_level": "low", + "trust_level": "verified", + "permissions": ["fs.read", "fs.write"], + "compatibility": { + "anyclaw_min": "0.1.0", + "os": ["windows", "linux", "darwin"], + "arch": ["amd64", "arm64"] + }, + "tags": ["skill", "authoring", "SKILL.md", "marketplace", "documentation"], + "hit_signals": ["create skill", "SKILL.md", "skill authoring", "marketplace skill", "authoring"], + "score": 0.97, + "manifest_summary": { + "entry": "skill/SKILL.md", + "use_case": "skill authoring" + } + }, + "versions": [ + { + "version": "1.0.0", + "released_at": "2026-05-07T00:00:00Z", + "changelog_md": "Initial Skill Author skill for creating AnyClaw marketplace-ready skills.", + "permissions": ["fs.read", "fs.write"], + "permissions_diff": ["fs.read", "fs.write"], + "compatibility": { + "anyclaw_min": "0.1.0", + "os": ["windows", "linux", "darwin"], + "arch": ["amd64", "arm64"] + } + } + ] +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 37283a35..280cd812 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -45,6 +45,12 @@ func clearConfigEnv(t *testing.T) { "ANYCLAW_WEBHOOK_SECRET", "ANYCLAW_RATE_LIMIT_RPM", "ANYCLAW_PLUGIN_EXEC_TIMEOUT", + "ANYCLAW_MARKETPLACE_ENDPOINT", + "ANYCLAW_REGISTRY_TOKEN", + "ANYCLAW_MARKETPLACE_DISABLE_REMOTE", + "ANYCLAW_MARKETPLACE_CACHE_TTL_SECONDS", + "ANYCLAW_MARKETPLACE_REQUEST_TIMEOUT_SECONDS", + "ANYCLAW_MARKETPLACE_AUTO_INSTALL_SKILL", } { t.Setenv(key, "") } @@ -70,6 +76,12 @@ func TestDefaultConfig(t *testing.T) { if cfg.Sandbox.DockerImage == "alpine:3.20" { t.Fatal("default sandbox docker image should use the bundled sandbox image, not plain Alpine") } + if cfg.Marketplace.ProtocolVersion != "1.0" { + t.Fatalf("default marketplace protocol = %q, want 1.0", cfg.Marketplace.ProtocolVersion) + } + if cfg.Marketplace.RegistryEndpoint != "" { + t.Fatalf("default marketplace registry endpoint should be empty, got %q", cfg.Marketplace.RegistryEndpoint) + } } func TestModularManagerMissingConfigUsesCanonicalDefaults(t *testing.T) { @@ -95,6 +107,9 @@ func TestModularManagerMissingConfigUsesCanonicalDefaults(t *testing.T) { if !reflect.DeepEqual(cfg.Security, defaults.Security) { t.Fatalf("modular default security drifted: %#v want %#v", cfg.Security, defaults.Security) } + if !reflect.DeepEqual(cfg.Marketplace, defaults.Marketplace) { + t.Fatalf("modular default marketplace drifted: %#v want %#v", cfg.Marketplace, defaults.Marketplace) + } } func TestValidateMissingProvider(t *testing.T) { @@ -439,6 +454,44 @@ func TestEnvOverrides(t *testing.T) { } } +func TestMarketplaceEnvOverrides(t *testing.T) { + clearConfigEnv(t) + t.Setenv("ANYCLAW_MARKETPLACE_ENDPOINT", "http://127.0.0.1:8791/") + t.Setenv("ANYCLAW_REGISTRY_TOKEN", "registry-token") + t.Setenv("ANYCLAW_MARKETPLACE_DISABLE_REMOTE", "true") + t.Setenv("ANYCLAW_MARKETPLACE_CACHE_TTL_SECONDS", "7") + t.Setenv("ANYCLAW_MARKETPLACE_REQUEST_TIMEOUT_SECONDS", "5") + t.Setenv("ANYCLAW_MARKETPLACE_AUTO_INSTALL_SKILL", "true") + + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + data, _ := json.MarshalIndent(DefaultConfig(), "", " ") + os.WriteFile(path, data, 0644) + + loaded, err := Load(path) + if err != nil { + t.Fatalf("loading config with marketplace env override should succeed: %v", err) + } + if loaded.Marketplace.RegistryEndpoint != "http://127.0.0.1:8791" { + t.Fatalf("expected normalized marketplace endpoint, got %q", loaded.Marketplace.RegistryEndpoint) + } + if loaded.Marketplace.RegistryToken != "registry-token" { + t.Fatalf("expected registry token from env, got %q", loaded.Marketplace.RegistryToken) + } + if !loaded.Marketplace.DisableRemote { + t.Fatal("expected marketplace remote to be disabled by env") + } + if loaded.Marketplace.CacheTTLSeconds != 7 { + t.Fatalf("expected cache ttl 7, got %d", loaded.Marketplace.CacheTTLSeconds) + } + if loaded.Marketplace.RequestTimeoutSeconds != 5 { + t.Fatalf("expected request timeout 5, got %d", loaded.Marketplace.RequestTimeoutSeconds) + } + if !loaded.Marketplace.AutoInstallSkill { + t.Fatal("expected auto install skill to be enabled by env") + } +} + func TestLoadPersistedSkipsEnvOverrides(t *testing.T) { clearConfigEnv(t) t.Setenv("OPENAI_API_KEY", "test-key-123") diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index b0a9f788..385bb2b6 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -127,6 +127,14 @@ func DefaultConfig() *Config { EnableDecomposition: true, SubAgents: nil, }, + Marketplace: MarketplaceConfig{ + ProtocolVersion: "1.0", + CacheTTLSeconds: 60, + RequestTimeoutSeconds: 30, + DownloadTimeoutSeconds: 300, + RetryCount: 1, + AutoInstallSkill: false, + }, } } diff --git a/pkg/config/env.go b/pkg/config/env.go index 0627169c..90c95823 100644 --- a/pkg/config/env.go +++ b/pkg/config/env.go @@ -110,4 +110,26 @@ func applyEnvOverrides(cfg *Config) { cfg.Plugins.ExecTimeoutSeconds = sec } } + if v := os.Getenv("ANYCLAW_MARKETPLACE_ENDPOINT"); v != "" { + cfg.Marketplace.RegistryEndpoint = v + } + if v := os.Getenv("ANYCLAW_REGISTRY_TOKEN"); v != "" { + cfg.Marketplace.RegistryToken = v + } + if v := os.Getenv("ANYCLAW_MARKETPLACE_DISABLE_REMOTE"); v != "" { + cfg.Marketplace.DisableRemote = strings.EqualFold(v, "1") || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes") + } + if v := os.Getenv("ANYCLAW_MARKETPLACE_CACHE_TTL_SECONDS"); v != "" { + if sec, err := strconv.Atoi(v); err == nil && sec >= 0 { + cfg.Marketplace.CacheTTLSeconds = sec + } + } + if v := os.Getenv("ANYCLAW_MARKETPLACE_REQUEST_TIMEOUT_SECONDS"); v != "" { + if sec, err := strconv.Atoi(v); err == nil && sec > 0 { + cfg.Marketplace.RequestTimeoutSeconds = sec + } + } + if v := os.Getenv("ANYCLAW_MARKETPLACE_AUTO_INSTALL_SKILL"); v != "" { + cfg.Marketplace.AutoInstallSkill = strings.EqualFold(v, "1") || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes") + } } diff --git a/pkg/config/normalize.go b/pkg/config/normalize.go index 3b3d765a..89b8432c 100644 --- a/pkg/config/normalize.go +++ b/pkg/config/normalize.go @@ -144,6 +144,24 @@ func normalizeLoadedConfig(cfg *Config) { cfg.LLM.BaseURL = strings.TrimSpace(cfg.LLM.BaseURL) cfg.LLM.DefaultProviderRef = strings.TrimSpace(cfg.LLM.DefaultProviderRef) cfg.LLM.Proxy = strings.TrimSpace(cfg.LLM.Proxy) + cfg.Marketplace.RegistryEndpoint = strings.TrimRight(strings.TrimSpace(cfg.Marketplace.RegistryEndpoint), "/") + cfg.Marketplace.RegistryToken = strings.TrimSpace(cfg.Marketplace.RegistryToken) + cfg.Marketplace.ProtocolVersion = strings.TrimSpace(cfg.Marketplace.ProtocolVersion) + if cfg.Marketplace.ProtocolVersion == "" { + cfg.Marketplace.ProtocolVersion = "1.0" + } + if cfg.Marketplace.CacheTTLSeconds < 0 { + cfg.Marketplace.CacheTTLSeconds = 0 + } + if cfg.Marketplace.RequestTimeoutSeconds <= 0 { + cfg.Marketplace.RequestTimeoutSeconds = 30 + } + if cfg.Marketplace.DownloadTimeoutSeconds <= 0 { + cfg.Marketplace.DownloadTimeoutSeconds = 300 + } + if cfg.Marketplace.RetryCount < 0 { + cfg.Marketplace.RetryCount = 0 + } cfg.Agent.Name = strings.TrimSpace(cfg.Agent.Name) cfg.Agent.Description = strings.TrimSpace(cfg.Agent.Description) cfg.Agent.ActiveProfile = strings.TrimSpace(cfg.Agent.ActiveProfile) diff --git a/pkg/config/types.go b/pkg/config/types.go index 93d2bc8e..7e6addf4 100644 --- a/pkg/config/types.go +++ b/pkg/config/types.go @@ -17,6 +17,19 @@ type Config struct { Orchestrator OrchestratorConfig `json:"orchestrator"` Speech SpeechConfig `json:"speech"` MCP MCPConfig `json:"mcp"` + Marketplace MarketplaceConfig `json:"marketplace"` +} + +type MarketplaceConfig struct { + RegistryEndpoint string `json:"registry_endpoint"` + RegistryToken string `json:"registry_token,omitempty"` + ProtocolVersion string `json:"protocol_version"` + DisableRemote bool `json:"disable_remote"` + CacheTTLSeconds int `json:"cache_ttl_seconds"` + RequestTimeoutSeconds int `json:"request_timeout_seconds"` + DownloadTimeoutSeconds int `json:"download_timeout_seconds"` + RetryCount int `json:"retry_count"` + AutoInstallSkill bool `json:"auto_install_skill"` } type MCPConfig struct { diff --git a/pkg/config/validate.go b/pkg/config/validate.go index a5b78318..f7316bce 100644 --- a/pkg/config/validate.go +++ b/pkg/config/validate.go @@ -94,6 +94,23 @@ func (c *Config) Validate() error { if c.Plugins.ExecTimeoutSeconds < 0 { errs = append(errs, fmt.Sprintf("plugins.exec_timeout_seconds must be >= 0 (got %d)", c.Plugins.ExecTimeoutSeconds)) } + if c.Marketplace.RegistryEndpoint != "" { + if !strings.HasPrefix(c.Marketplace.RegistryEndpoint, "http://") && !strings.HasPrefix(c.Marketplace.RegistryEndpoint, "https://") { + errs = append(errs, fmt.Sprintf("marketplace.registry_endpoint must start with http:// or https:// (got %q)", c.Marketplace.RegistryEndpoint)) + } + } + if c.Marketplace.CacheTTLSeconds < 0 { + errs = append(errs, fmt.Sprintf("marketplace.cache_ttl_seconds must be >= 0 (got %d)", c.Marketplace.CacheTTLSeconds)) + } + if c.Marketplace.RequestTimeoutSeconds < 0 { + errs = append(errs, fmt.Sprintf("marketplace.request_timeout_seconds must be >= 0 (got %d)", c.Marketplace.RequestTimeoutSeconds)) + } + if c.Marketplace.DownloadTimeoutSeconds < 0 { + errs = append(errs, fmt.Sprintf("marketplace.download_timeout_seconds must be >= 0 (got %d)", c.Marketplace.DownloadTimeoutSeconds)) + } + if c.Marketplace.RetryCount < 0 { + errs = append(errs, fmt.Sprintf("marketplace.retry_count must be >= 0 (got %d)", c.Marketplace.RetryCount)) + } validBackends := map[string]bool{"local": true, "docker": true} if c.Sandbox.Backend != "" && !validBackends[c.Sandbox.Backend] { diff --git a/pkg/marketplace/bindings_test.go b/pkg/marketplace/bindings_test.go new file mode 100644 index 00000000..4ea8396c --- /dev/null +++ b/pkg/marketplace/bindings_test.go @@ -0,0 +1,62 @@ +package marketplace + +import ( + "testing" + "time" +) + +func TestStoreBindingsDeriveArtifactStatus(t *testing.T) { + store := NewStore(t.TempDir()) + receipt := &InstallReceipt{ + ID: "cloud.skill.release-notes@1.0.0", + ArtifactID: "cloud.skill.release-notes", + Kind: ArtifactKindSkill, + Name: "Release Notes", + Version: "1.0.0", + Source: SourceCloud, + InstalledPath: "/tmp/release-notes", + InstalledBy: "user", + InstalledAt: time.Now().UTC().Format(time.RFC3339), + } + if err := store.SaveReceipt(receipt); err != nil { + t.Fatal(err) + } + + items := store.OverlayStatus([]Artifact{{ + ID: "cloud.skill.release-notes", + Kind: ArtifactKindSkill, + Source: SourceCloud, + Status: StatusAvailable, + }}) + if items[0].Status != StatusInstalled || !items[0].Installed || items[0].Bound || items[0].Active { + t.Fatalf("expected installed-only status, got %#v", items[0]) + } + + binding, err := store.CreateBinding(BindingRequest{ + ArtifactID: receipt.ArtifactID, + TargetType: TargetWorkspace, + TargetID: "workspace-1", + }) + if err != nil { + t.Fatal(err) + } + items = store.OverlayStatus(items) + if items[0].Status != StatusBound || !items[0].Bound || items[0].Active { + t.Fatalf("expected bound status, got %#v", items[0]) + } + + if err := store.DeleteBinding(binding.ID); err != nil { + t.Fatal(err) + } + if _, err := store.CreateBinding(BindingRequest{ + ArtifactID: receipt.ArtifactID, + TargetType: TargetMainAgent, + TargetID: "Main Agent", + }); err != nil { + t.Fatal(err) + } + items = store.OverlayStatus(items) + if items[0].Status != StatusActive || !items[0].Active { + t.Fatalf("expected active status for main_agent binding, got %#v", items[0]) + } +} diff --git a/pkg/marketplace/capability_index.go b/pkg/marketplace/capability_index.go new file mode 100644 index 00000000..64c1c41a --- /dev/null +++ b/pkg/marketplace/capability_index.go @@ -0,0 +1,108 @@ +package marketplace + +import "strings" + +func BuildCapabilityIndex(items []Artifact) []CapabilityIndexItem { + out := make([]CapabilityIndexItem, 0, len(items)) + for _, item := range items { + out = append(out, CapabilityIndexItem{ + ArtifactID: item.ID, + Kind: item.Kind, + Name: firstNonEmpty(item.DisplayName, item.Name), + Source: item.Source, + Status: string(item.Status), + Capabilities: artifactCapabilityTerms(item), + Permissions: append([]string(nil), item.Permissions...), + RiskLevel: item.RiskLevel, + TrustLevel: item.TrustLevel, + Score: item.Score, + }) + } + return out +} + +func DetectCapabilityNeed(input string) string { + normalized := strings.ToLower(strings.TrimSpace(input)) + switch { + case containsAny(normalized, "release note", "changelog", "发布说明", "版本说明"): + return "release notes" + case containsAny(normalized, "code review", "review code", "pull request", "风险检查", "代码审查"): + return "code review" + case containsAny(normalized, "repo health", "repository health", "diagnose repo", "仓库健康", "诊断"): + return "repo health" + default: + return strings.TrimSpace(input) + } +} + +func RouteCapabilityNeed(need string, installed []Artifact, cloud []Artifact, limit int) CapabilityRoute { + need = DetectCapabilityNeed(need) + if limit <= 0 { + limit = 5 + } + installedMatches := matchCapabilityIndex(BuildCapabilityIndex(installed), need, 1) + if len(installedMatches) > 0 { + match := installedMatches[0] + return CapabilityRoute{ + Need: need, + InstalledMatch: &match, + Action: "use_installed", + Reason: "matching installed capability is already available", + } + } + cloudMatches := matchCapabilityIndex(BuildCapabilityIndex(cloud), need, limit) + if len(cloudMatches) > 0 { + return CapabilityRoute{ + Need: need, + CloudMatches: cloudMatches, + Action: "install_from_market", + Reason: "cloud marketplace has matching capabilities", + } + } + return CapabilityRoute{ + Need: need, + Action: "no_match", + Reason: "no installed or cloud marketplace capability matched the need", + } +} + +func matchCapabilityIndex(items []CapabilityIndexItem, need string, limit int) []CapabilityIndexItem { + terms := strings.Fields(strings.ToLower(strings.TrimSpace(need))) + if len(terms) == 0 { + return nil + } + var out []CapabilityIndexItem + for _, item := range items { + haystack := strings.ToLower(strings.Join(append([]string{item.ArtifactID, item.Name, string(item.Kind)}, item.Capabilities...), " ")) + matches := true + for _, term := range terms { + if !strings.Contains(haystack, term) { + matches = false + break + } + } + if !matches { + continue + } + out = append(out, item) + if limit > 0 && len(out) >= limit { + return out + } + } + return out +} + +func artifactCapabilityTerms(item Artifact) []string { + return appendUniqueStrings(nil, + append(append(append([]string{}, item.Capabilities...), item.Tags...), item.HitSignals...)..., + ) +} + +func containsAny(value string, needles ...string) bool { + for _, needle := range needles { + if strings.Contains(value, strings.ToLower(strings.TrimSpace(needle))) { + return true + } + } + return false +} diff --git a/pkg/marketplace/capability_index_test.go b/pkg/marketplace/capability_index_test.go new file mode 100644 index 00000000..2322b3d2 --- /dev/null +++ b/pkg/marketplace/capability_index_test.go @@ -0,0 +1,40 @@ +package marketplace + +import "testing" + +func TestRouteCapabilityNeedPrefersInstalledCapability(t *testing.T) { + installed := []Artifact{{ + ID: "cloud.skill.release-notes", + Kind: ArtifactKindSkill, + Name: "Release Notes Writer", + Source: SourceLocal, + Status: StatusInstalled, + Capabilities: []string{"release notes", "changelog"}, + }} + cloud := []Artifact{{ + ID: "cloud.skill.release-notes-v2", + Kind: ArtifactKindSkill, + Name: "Release Notes Writer v2", + Source: SourceCloud, + Status: StatusAvailable, + Capabilities: []string{"release notes"}, + }} + route := RouteCapabilityNeed("please write release notes", installed, cloud, 5) + if route.Action != "use_installed" || route.InstalledMatch == nil || route.InstalledMatch.ArtifactID != "cloud.skill.release-notes" { + t.Fatalf("route = %#v, want installed match", route) + } +} + +func TestRouteCapabilityNeedSuggestsCloudInstall(t *testing.T) { + route := RouteCapabilityNeed("review this pull request", nil, []Artifact{{ + ID: "cloud.agent.code-reviewer", + Kind: ArtifactKindAgent, + Name: "Code Reviewer", + Source: SourceCloud, + Status: StatusAvailable, + Capabilities: []string{"code review", "pull request"}, + }}, 5) + if route.Action != "install_from_market" || len(route.CloudMatches) != 1 { + t.Fatalf("route = %#v, want cloud match", route) + } +} diff --git a/pkg/marketplace/catalog.go b/pkg/marketplace/catalog.go new file mode 100644 index 00000000..1ab41f8c --- /dev/null +++ b/pkg/marketplace/catalog.go @@ -0,0 +1,561 @@ +package marketplace + +import ( + "context" + "errors" + "sort" + "strings" + + agentstore "github.com/1024XEngineer/anyclaw/pkg/capability/catalogs" + "github.com/1024XEngineer/anyclaw/pkg/capability/skills" + "github.com/1024XEngineer/anyclaw/pkg/clihub" + "github.com/1024XEngineer/anyclaw/pkg/config" + "github.com/1024XEngineer/anyclaw/pkg/extensions/plugin" +) + +var ErrArtifactNotFound = errors.New("artifact not found") + +type LocalCatalogDeps struct { + Config *config.Config + Skills *skills.SkillsManager + Plugins *plugin.Registry + AgentStore agentstore.StoreManager + CLIHub *clihub.Catalog + WorkspaceID string +} + +type LocalCatalog struct { + deps LocalCatalogDeps +} + +func NewLocalCatalog(deps LocalCatalogDeps) *LocalCatalog { + return &LocalCatalog{deps: deps} +} + +func (c *LocalCatalog) List(ctx context.Context, filter Filter) (ListResult, error) { + _ = ctx + items := c.collect(filter) + items = filterArtifacts(items, filter) + sortArtifacts(items) + + total := len(items) + offset := filter.Offset + if offset < 0 { + offset = 0 + } + if offset > total { + offset = total + } + limit := filter.Limit + if limit <= 0 { + limit = 50 + } + end := offset + limit + if end > total { + end = total + } + + return ListResult{ + Items: append([]Artifact(nil), items[offset:end]...), + Total: total, + Limit: limit, + Offset: offset, + }, nil +} + +func (c *LocalCatalog) Get(ctx context.Context, id string) (*Artifact, error) { + _ = ctx + id = strings.TrimSpace(id) + if id == "" { + return nil, ErrArtifactNotFound + } + for _, item := range c.collect(Filter{}) { + if strings.EqualFold(item.ID, id) { + copy := item + return ©, nil + } + } + return nil, ErrArtifactNotFound +} + +func (c *LocalCatalog) Versions(ctx context.Context, id string) ([]ArtifactVersion, error) { + artifact, err := c.Get(ctx, id) + if err != nil { + return nil, err + } + if strings.TrimSpace(artifact.Version) == "" { + return nil, nil + } + return []ArtifactVersion{{ + Version: artifact.Version, + Compatibility: artifact.Compatibility, + }}, nil +} + +func (c *LocalCatalog) collect(filter Filter) []Artifact { + var items []Artifact + if filter.Source != "" && filter.Source != SourceLocal { + return items + } + + if filter.Kind == "" || filter.Kind == ArtifactKindAgent { + items = append(items, c.agentProfileArtifacts()...) + items = append(items, c.agentStoreArtifacts()...) + } + if filter.Kind == "" || filter.Kind == ArtifactKindSkill { + items = append(items, c.skillArtifacts()...) + } + if filter.Kind == "" || filter.Kind == ArtifactKindCLI { + items = append(items, c.cliArtifacts()...) + } + if filter.Kind == "" || filter.Kind == ArtifactKindPlugin { + items = append(items, c.pluginArtifacts()...) + } + return dedupeArtifacts(items) +} + +func (c *LocalCatalog) agentProfileArtifacts() []Artifact { + cfg := c.deps.Config + if cfg == nil { + return nil + } + + active := strings.TrimSpace(cfg.ResolveMainAgentName()) + items := make([]Artifact, 0, len(cfg.Agent.Profiles)+1) + for _, profile := range cfg.Agent.Profiles { + name := strings.TrimSpace(profile.Name) + if name == "" { + continue + } + enabled := profile.IsEnabled() + isActive := strings.EqualFold(name, active) + status := StatusBound + if isActive { + status = StatusActive + } else if !enabled { + status = StatusDisabled + } + items = append(items, Artifact{ + ID: artifactID(ArtifactKindAgent, name), + Kind: ArtifactKindAgent, + Name: name, + DisplayName: name, + Description: firstNonEmpty(profile.Description, profile.Persona, profile.Role), + Source: SourceLocal, + SourceID: "agent.profiles", + Status: status, + Installed: true, + Bound: true, + Active: isActive, + Enabled: enabled, + Category: firstNonEmpty(profile.Domain, profile.Role, "agent"), + Tags: appendUnique(profile.Expertise, profile.Role, profile.Domain), + Permissions: agentPermissions(profile), + TargetHints: []string{"main_agent", "persistent_subagent", "workspace"}, + Capabilities: agentCapabilities(profile), + Metadata: map[string]string{ + "provider_ref": profile.ProviderRef, + "working_dir": profile.WorkingDir, + }, + }) + } + + if len(items) == 0 && strings.TrimSpace(cfg.Agent.Name) != "" { + name := strings.TrimSpace(cfg.Agent.Name) + items = append(items, Artifact{ + ID: artifactID(ArtifactKindAgent, name), + Kind: ArtifactKindAgent, + Name: name, + DisplayName: name, + Description: cfg.Agent.Description, + Source: SourceLocal, + SourceID: "agent.config", + Status: StatusActive, + Installed: true, + Bound: true, + Active: true, + Enabled: true, + Category: "agent", + Permissions: []string{cfg.Agent.PermissionLevel}, + TargetHints: []string{"main_agent"}, + Metadata: map[string]string{ + "working_dir": cfg.Agent.WorkingDir, + }, + }) + } + return items +} + +func (c *LocalCatalog) agentStoreArtifacts() []Artifact { + store := c.deps.AgentStore + if store == nil { + return nil + } + packages := store.List(agentstore.StoreFilter{}) + items := make([]Artifact, 0, len(packages)) + for _, pkg := range packages { + id := strings.TrimSpace(pkg.ID) + if id == "" { + continue + } + installed := store.IsInstalled(id) + status := StatusAvailable + if installed { + status = StatusInstalled + } + items = append(items, Artifact{ + ID: artifactID(ArtifactKindAgent, id), + Kind: ArtifactKindAgent, + Name: firstNonEmpty(pkg.Name, id), + DisplayName: firstNonEmpty(pkg.DisplayName, pkg.Name, id), + Description: pkg.Description, + Version: pkg.Version, + LatestVersion: pkg.Version, + Source: SourceLocal, + SourceID: "agentstore", + Status: status, + Installed: installed, + Enabled: true, + Owner: pkg.Author, + Category: firstNonEmpty(pkg.Category, pkg.Domain, "agent"), + Tags: appendUnique(pkg.Tags, pkg.Domain), + Permissions: []string{pkg.Permission}, + InstallHint: "anyclaw store install " + id, + TargetHints: []string{"persistent_subagent", "workspace"}, + Capabilities: appendUnique(appendUnique(pkg.Expertise, pkg.Skills...), pkg.Domain), + Metadata: map[string]string{ + "store_id": id, + "persona": pkg.Persona, + }, + }) + } + return items +} + +func (c *LocalCatalog) skillArtifacts() []Artifact { + manager := c.deps.Skills + if manager == nil { + return nil + } + entries := manager.Catalog() + items := make([]Artifact, 0, len(entries)) + for _, entry := range entries { + name := strings.TrimSpace(entry.Name) + if name == "" { + continue + } + status := StatusInstalled + if !entry.Installed { + status = StatusAvailable + } + items = append(items, Artifact{ + ID: artifactID(ArtifactKindSkill, name), + Kind: ArtifactKindSkill, + Name: name, + DisplayName: firstNonEmpty(entry.FullName, name), + Description: entry.Description, + Version: entry.Version, + LatestVersion: entry.Version, + Source: SourceLocal, + SourceID: firstNonEmpty(entry.Registry, entry.Source, "skills"), + Status: status, + Installed: entry.Installed, + Enabled: true, + Category: firstNonEmpty(entry.Category, "skill"), + Tags: appendUnique(nil, entry.Category, entry.Registry, entry.Source), + Permissions: append([]string(nil), entry.Permissions...), + InstallHint: entry.InstallHint, + TargetHints: []string{"main_agent", "persistent_subagent", "workspace"}, + Capabilities: appendUnique(nil, entry.Category, name), + Metadata: map[string]string{ + "homepage": entry.Homepage, + "entrypoint": entry.Entrypoint, + "installed_dir": entry.InstalledDir, + "builtin": boolString(entry.Builtin), + }, + }) + } + return items +} + +func (c *LocalCatalog) cliArtifacts() []Artifact { + catalog := c.deps.CLIHub + if catalog == nil { + return nil + } + entries := clihub.Search(catalog, "", "", false, 0) + items := make([]Artifact, 0, len(entries)) + for _, entry := range entries { + name := strings.TrimSpace(entry.Name) + if name == "" { + continue + } + status := StatusAvailable + installed := entry.Installed + if entry.Runnable { + status = StatusInstalled + } + items = append(items, Artifact{ + ID: artifactID(ArtifactKindCLI, name), + Kind: ArtifactKindCLI, + Name: name, + DisplayName: firstNonEmpty(entry.DisplayName, name), + Description: entry.Description, + Version: entry.Version, + LatestVersion: entry.Version, + Source: SourceLocal, + SourceID: "clihub", + Status: status, + Installed: installed, + Enabled: entry.Runnable, + Owner: entry.Contributor, + Category: firstNonEmpty(entry.Category, "cli"), + Tags: appendUnique(nil, entry.Category, entry.Requires), + Permissions: cliPermissions(entry), + InstallHint: entry.InstallCmd, + TargetHints: []string{"workspace", "runtime_global"}, + Capabilities: appendUnique(nil, entry.Name, entry.DisplayName, entry.EntryPoint, entry.Category), + Metadata: map[string]string{ + "entrypoint": entry.EntryPoint, + "homepage": entry.Homepage, + "requires": entry.Requires, + "run_mode": entry.RunMode, + "executable_path": entry.ExecutablePath, + "source_path": entry.SourcePath, + "skill_path": entry.SkillPath, + "dev_module": entry.DevModule, + "catalog_root": catalog.Root, + }, + }) + } + return items +} + +func (c *LocalCatalog) pluginArtifacts() []Artifact { + registry := c.deps.Plugins + if registry == nil { + return nil + } + manifests := registry.List() + items := make([]Artifact, 0, len(manifests)) + for _, manifest := range manifests { + name := strings.TrimSpace(manifest.Name) + if name == "" { + continue + } + status := StatusInstalled + if !manifest.Enabled { + status = StatusDisabled + } + items = append(items, Artifact{ + ID: artifactID(ArtifactKindPlugin, name), + Kind: ArtifactKindPlugin, + Name: name, + DisplayName: name, + Description: manifest.Description, + Version: manifest.Version, + LatestVersion: manifest.Version, + Source: SourceLocal, + SourceID: "plugins", + Status: status, + Installed: true, + Enabled: manifest.Enabled, + Category: firstNonEmpty(firstString(manifest.Kinds), "plugin"), + Tags: appendUnique(manifest.CapabilityTags, manifest.Kinds...), + Permissions: append([]string(nil), manifest.Permissions...), + RiskLevel: manifest.RiskLevel, + TrustLevel: manifest.Trust, + Verified: manifest.Verified, + TargetHints: []string{"runtime_global", "workspace"}, + Capabilities: appendUnique(manifest.CapabilityTags, manifest.Kinds...), + Metadata: map[string]string{ + "entrypoint": manifest.Entrypoint, + "approval_scope": manifest.ApprovalScope, + "builtin": boolString(manifest.Builtin), + }, + }) + } + return items +} + +func filterArtifacts(items []Artifact, filter Filter) []Artifact { + query := strings.ToLower(strings.TrimSpace(filter.Query)) + out := make([]Artifact, 0, len(items)) + for _, item := range items { + if filter.Kind != "" && item.Kind != filter.Kind { + continue + } + if filter.Source != "" && item.Source != filter.Source { + continue + } + if filter.Status != "" && item.Status != filter.Status { + continue + } + if filter.Risk != "" && !strings.EqualFold(item.RiskLevel, filter.Risk) { + continue + } + if filter.Trust != "" && !strings.EqualFold(item.TrustLevel, filter.Trust) { + continue + } + if filter.Tag != "" && !containsFold(item.Tags, filter.Tag) { + continue + } + if filter.Permission != "" && !containsFold(item.Permissions, filter.Permission) { + continue + } + if filter.Publisher != "" && !strings.Contains(strings.ToLower(item.Owner), strings.ToLower(strings.TrimSpace(filter.Publisher))) { + continue + } + if filter.OS != "" && len(item.Compatibility.OS) > 0 && !containsFold(item.Compatibility.OS, filter.OS) { + continue + } + if filter.Arch != "" && len(item.Compatibility.Arch) > 0 && !containsFold(item.Compatibility.Arch, filter.Arch) { + continue + } + if query != "" && !artifactMatches(item, query) { + continue + } + out = append(out, item) + } + return out +} + +func artifactMatches(item Artifact, query string) bool { + parts := []string{ + item.ID, + item.Name, + item.DisplayName, + item.Description, + item.Version, + item.Owner, + item.Category, + string(item.Kind), + string(item.Source), + item.SourceID, + item.Status.String(), + } + parts = append(parts, item.Tags...) + parts = append(parts, item.Permissions...) + parts = append(parts, item.Capabilities...) + for k, v := range item.Metadata { + parts = append(parts, k, v) + } + return strings.Contains(strings.ToLower(strings.Join(parts, " ")), query) +} + +func containsFold(values []string, want string) bool { + want = strings.ToLower(strings.TrimSpace(want)) + if want == "" { + return true + } + for _, value := range values { + if strings.EqualFold(strings.TrimSpace(value), want) { + return true + } + } + return false +} + +func sortArtifacts(items []Artifact) { + sort.SliceStable(items, func(i, j int) bool { + if items[i].Kind != items[j].Kind { + return items[i].Kind < items[j].Kind + } + if items[i].Source != items[j].Source { + return items[i].Source < items[j].Source + } + return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name) + }) +} + +func dedupeArtifacts(items []Artifact) []Artifact { + seen := make(map[string]bool, len(items)) + out := make([]Artifact, 0, len(items)) + for _, item := range items { + if strings.TrimSpace(item.ID) == "" { + continue + } + key := string(item.Source) + ":" + item.ID + ":" + item.SourceID + if seen[key] { + continue + } + seen[key] = true + out = append(out, item) + } + return out +} + +func (s ArtifactStatus) String() string { + return string(s) +} + +func artifactID(kind ArtifactKind, name string) string { + return string(kind) + ":" + strings.TrimSpace(name) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func firstString(values []string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func appendUnique(base []string, values ...string) []string { + out := append([]string(nil), base...) + seen := make(map[string]bool, len(out)+len(values)) + filtered := make([]string, 0, len(out)+len(values)) + for _, value := range append(out, values...) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + key := strings.ToLower(trimmed) + if seen[key] { + continue + } + seen[key] = true + filtered = append(filtered, trimmed) + } + return filtered +} + +func boolString(value bool) string { + if value { + return "true" + } + return "false" +} + +func agentPermissions(profile config.AgentProfile) []string { + if strings.TrimSpace(profile.PermissionLevel) == "" { + return nil + } + return []string{profile.PermissionLevel} +} + +func agentCapabilities(profile config.AgentProfile) []string { + values := appendUnique(profile.Expertise, profile.Domain, profile.Role) + for _, skill := range profile.Skills { + values = appendUnique(values, skill.Name) + } + return values +} + +func cliPermissions(entry clihub.EntryStatus) []string { + permissions := []string{"process.exec"} + if strings.TrimSpace(entry.Homepage) != "" || strings.Contains(strings.ToLower(entry.InstallCmd), "http") { + permissions = append(permissions, "network.http") + } + return permissions +} diff --git a/pkg/marketplace/catalog_test.go b/pkg/marketplace/catalog_test.go new file mode 100644 index 00000000..a53e53c4 --- /dev/null +++ b/pkg/marketplace/catalog_test.go @@ -0,0 +1,214 @@ +package marketplace + +import ( + "context" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/1024XEngineer/anyclaw/pkg/capability/skills" + "github.com/1024XEngineer/anyclaw/pkg/clihub" + "github.com/1024XEngineer/anyclaw/pkg/config" + "github.com/1024XEngineer/anyclaw/pkg/extensions/plugin" +) + +func TestLocalCatalogListsCLIHubArtifacts(t *testing.T) { + catalog := NewLocalCatalog(LocalCatalogDeps{ + CLIHub: &clihub.Catalog{ + Root: "C:/cli-anything", + Entries: []clihub.Entry{ + { + Name: "drawio", + DisplayName: "Draw.io", + Version: "1.2.3", + Description: "Diagram automation", + Category: "diagram", + EntryPoint: "drawio", + InstallCmd: "https://example.test/install", + }, + }, + }, + }) + + result, err := catalog.List(context.Background(), Filter{Kind: ArtifactKindCLI, Source: SourceLocal}) + if err != nil { + t.Fatalf("List: %v", err) + } + if result.Total != 1 { + t.Fatalf("expected 1 cli artifact, got %d: %#v", result.Total, result.Items) + } + item := result.Items[0] + if item.ID != "cli:drawio" || item.Kind != ArtifactKindCLI { + t.Fatalf("unexpected cli artifact identity: %#v", item) + } + if item.SourceID != "clihub" { + t.Fatalf("expected clihub source id, got %q", item.SourceID) + } + if item.Status != StatusAvailable { + t.Fatalf("expected unavailable catalog entry status to be available, got %q", item.Status) + } + if !containsString(item.Permissions, "process.exec") || !containsString(item.Permissions, "network.http") { + t.Fatalf("expected cli permissions to include process.exec and network.http, got %#v", item.Permissions) + } +} + +func TestLocalCatalogGetAndVersions(t *testing.T) { + catalog := NewLocalCatalog(LocalCatalogDeps{ + Config: &config.Config{ + Agent: config.AgentConfig{ + Name: "main", + Description: "Main agent", + PermissionLevel: "limited", + }, + }, + }) + + artifact, err := catalog.Get(context.Background(), "agent:main") + if err != nil { + t.Fatalf("Get: %v", err) + } + if artifact.Status != StatusActive { + t.Fatalf("expected active main agent, got %q", artifact.Status) + } + + versions, err := catalog.Versions(context.Background(), "agent:main") + if err != nil { + t.Fatalf("Versions: %v", err) + } + if len(versions) != 0 { + t.Fatalf("expected no versions for unversioned local agent, got %#v", versions) + } +} + +func TestLocalCatalogListsProfilesSkillsPluginsAndFilters(t *testing.T) { + baseDir := t.TempDir() + skillsDir := filepath.Join(baseDir, "skills") + pluginDir := filepath.Join(baseDir, "plugins") + if err := os.MkdirAll(filepath.Join(skillsDir, "release-notes"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillsDir, "release-notes", "skill.json"), []byte(`{ + "name":"release-notes", + "full_name":"Release Notes", + "description":"Writes release notes", + "version":"1.2.0", + "category":"writing", + "permissions":["fs.read"], + "source":"local" + }`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(pluginDir, "workflow", ".codex-plugin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "workflow", ".codex-plugin", "plugin.json"), []byte(`{ + "name":"workflow", + "version":"0.5.0", + "description":"Workflow plugin", + "kinds":["automation"], + "enabled":true, + "permissions":["network.http"], + "capability_tags":["workflow"], + "risk_level":"medium", + "trust":"verified", + "verified":true + }`), 0o644); err != nil { + t.Fatal(err) + } + cfg := config.DefaultConfig() + cfg.Agent.ActiveProfile = "Main" + cfg.Agent.Profiles = []config.AgentProfile{ + {Name: "Main", Enabled: config.BoolPtr(true), Role: "coder", Domain: "engineering", Expertise: []string{"go"}, Skills: []config.AgentSkillRef{{Name: "release-notes"}}, PermissionLevel: "limited"}, + {Name: "Dormant", Enabled: config.BoolPtr(false), Role: "writer"}, + } + cfg.Skills.Dir = skillsDir + plugins, err := plugin.NewRegistry(config.PluginsConfig{Dir: pluginDir}) + if err != nil { + t.Fatal(err) + } + catalog := NewLocalCatalog(LocalCatalogDeps{ + Config: cfg, + Skills: loadedSkillsManager(t, skillsDir), + Plugins: plugins, + }) + + result, err := catalog.List(context.Background(), Filter{Source: SourceLocal, Limit: 2, Offset: -10}) + if err != nil { + t.Fatal(err) + } + if result.Limit != 2 || result.Offset != 0 || len(result.Items) != 2 || result.Total < 4 { + t.Fatalf("unexpected paged result: %#v", result) + } + skill, err := catalog.Get(context.Background(), "skill:release-notes") + if err != nil { + t.Fatal(err) + } + if skill.Status != StatusInstalled || skill.SourceID != "local" || !containsString(skill.Capabilities, "writing") { + t.Fatalf("unexpected skill artifact: %#v", skill) + } + versions, err := catalog.Versions(context.Background(), "skill:release-notes") + if err != nil { + t.Fatal(err) + } + if len(versions) != 1 || versions[0].Version != "1.2.0" { + t.Fatalf("unexpected versions: %#v", versions) + } + filtered, err := catalog.List(context.Background(), Filter{Kind: ArtifactKindPlugin, Query: "workflow", Risk: "medium", Trust: "verified", Permission: "network.http", Tag: "automation", Limit: 10}) + if err != nil { + t.Fatal(err) + } + if filtered.Total != 1 || filtered.Items[0].ID != "plugin:workflow" || !filtered.Items[0].Verified { + t.Fatalf("unexpected filtered plugin: %#v", filtered) + } + empty, err := catalog.List(context.Background(), Filter{Source: SourceCloud}) + if err != nil { + t.Fatal(err) + } + if empty.Total != 0 { + t.Fatalf("expected no local results for cloud filter, got %#v", empty) + } + if _, err := catalog.Get(context.Background(), ""); err != ErrArtifactNotFound { + t.Fatalf("expected ErrArtifactNotFound for empty id, got %v", err) + } +} + +func loadedSkillsManager(t *testing.T, dir string) *skills.SkillsManager { + t.Helper() + manager := skills.NewSkillsManager(dir) + if err := manager.Load(); err != nil { + t.Fatal(err) + } + return manager +} + +func TestCatalogHelpers(t *testing.T) { + items := []Artifact{ + {ID: "", Source: SourceLocal}, + {ID: "skill:a", Name: "A", Source: SourceLocal, SourceID: "skills"}, + {ID: "skill:a", Name: "A duplicate", Source: SourceLocal, SourceID: "skills"}, + {ID: "skill:a", Name: "A cloud", Source: SourceCloud, SourceID: "cloud"}, + } + deduped := dedupeArtifacts(items) + if len(deduped) != 2 { + t.Fatalf("deduped = %#v", deduped) + } + if !containsFold([]string{" Alpha "}, "alpha") || !containsFold([]string{"Alpha"}, "") { + t.Fatal("containsFold did not match expected values") + } + if got := appendUnique([]string{"A"}, "a", "B", " "); !reflect.DeepEqual(got, []string{"A", "B"}) { + t.Fatalf("appendUnique = %#v", got) + } + if firstNonEmpty(" ", "x") != "x" || firstString([]string{"", "y"}) != "y" || boolString(true) != "true" || boolString(false) != "false" { + t.Fatal("basic helper output mismatch") + } +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/pkg/marketplace/installer.go b/pkg/marketplace/installer.go new file mode 100644 index 00000000..b50bbc40 --- /dev/null +++ b/pkg/marketplace/installer.go @@ -0,0 +1,524 @@ +package marketplace + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +type RegistryResolver interface { + Resolve(ctx context.Context, artifactID, versionConstraint string) (ResolvedPackage, error) + Download(ctx context.Context, rawURL string) ([]byte, error) +} + +type ResolvedPackage struct { + ArtifactID string `json:"artifact_id"` + Version string `json:"version"` + DownloadURL string `json:"download_url"` + ChecksumSHA256 string `json:"checksum_sha256"` + SizeBytes int64 `json:"size_bytes"` + Compatibility Compatibility `json:"compatibility"` + Dependencies []ArtifactDependency `json:"dependencies,omitempty"` + RiskLevel string `json:"risk_level"` + TrustLevel string `json:"trust_level"` + Permissions []string `json:"permissions"` + Signature string `json:"signature,omitempty"` + Kind ArtifactKind `json:"kind"` + Name string `json:"name"` +} + +type InstallUseCase struct { + store *Store + registry RegistryResolver + policy DecisionPolicy +} + +func NewInstallUseCase(store *Store, registry RegistryResolver) *InstallUseCase { + return NewInstallUseCaseWithPolicy(store, registry, PolicyConfig{}) +} + +func NewInstallUseCaseWithPolicy(store *Store, registry RegistryResolver, cfg PolicyConfig) *InstallUseCase { + return &InstallUseCase{store: store, registry: registry, policy: NewDecisionPolicy(cfg)} +} + +func (uc *InstallUseCase) Start(ctx context.Context, req InstallRequest) (*InstallJob, bool, error) { + if uc == nil || uc.store == nil { + return nil, false, fmt.Errorf("marketplace install store is not configured") + } + if strings.TrimSpace(req.ArtifactID) == "" { + return nil, false, fmt.Errorf("artifact_id is required") + } + job, reused, err := uc.store.CreateInstallJob(req, req.IdempotencyKey) + if err == nil && !reused { + uc.audit("market.install.started", job, nil, nil) + uc.event("market.install.started", "info", "Marketplace install started", job, nil) + } + return job, reused, err +} + +func (uc *InstallUseCase) Execute(ctx context.Context, jobID string) error { + if uc == nil || uc.store == nil || uc.registry == nil { + return fmt.Errorf("marketplace installer is not configured") + } + job, err := uc.store.GetJob(jobID) + if err != nil { + return err + } + if job.State == JobSucceeded || job.State == JobFailed || job.State == JobRolledBack { + return nil + } + if err := uc.mark(job, JobRunning, "resolve", 1, ""); err != nil { + return err + } + resolved, err := uc.registry.Resolve(ctx, job.ArtifactID, job.VersionConstraint) + if err != nil { + return uc.fail(job, err, false) + } + userConfirmed := strings.EqualFold(job.Metadata["user_confirmed"], "true") + riskAcknowledged := strings.EqualFold(job.Metadata["risk_acknowledged"], "true") + job.Version = resolved.Version + job.ChecksumSHA256 = resolved.ChecksumSHA256 + metadata := cloneStringMap(job.Metadata) + if metadata == nil { + metadata = map[string]string{} + } + metadata["download_url"] = resolved.DownloadURL + job.Metadata = metadata + decision := uc.policy.DecideInstall(InstallRequest{ + ArtifactID: job.ArtifactID, + VersionConstraint: job.VersionConstraint, + InstalledBy: job.InstalledBy, + UserConfirmed: userConfirmed, + RiskAcknowledged: riskAcknowledged, + }, resolved) + job.Decision = &decision + _ = uc.store.UpdateJob(job) + uc.audit("market.policy.decision", job, &decision, map[string]any{ + "kind": resolved.Kind, + "version": resolved.Version, + "risk_level": resolved.RiskLevel, + "trust_level": resolved.TrustLevel, + "permissions": resolved.Permissions, + }) + uc.event("market.policy.decision", eventLevelForDecision(decision), decision.Reason, job, &decision) + if decision.Decision == DecisionBlock { + return uc.fail(job, fmt.Errorf("marketplace policy blocked install: %s", decision.Reason), false) + } + if decision.Decision == DecisionAsk && decision.RequiresUserConfirmation { + return uc.fail(job, fmt.Errorf("marketplace policy requires user confirmation: %s", decision.Reason), false) + } + if decision.Decision == DecisionAsk && decision.RequiresRiskAcknowledgement { + return uc.fail(job, fmt.Errorf("marketplace policy requires high-risk permission acknowledgement: %s", decision.Reason), false) + } + if strings.TrimSpace(resolved.ChecksumSHA256) == "" { + decision := PolicyDecision{Decision: DecisionBlock, Reason: "missing checksum", Reasons: []string{"missing checksum"}} + job.Decision = &decision + _ = uc.store.UpdateJob(job) + uc.audit("market.policy.decision", job, &decision, map[string]any{"artifact_id": resolved.ArtifactID, "version": resolved.Version}) + uc.event("market.policy.decision", "error", decision.Reason, job, &decision) + return uc.fail(job, fmt.Errorf("marketplace policy blocked install: %s", decision.Reason), false) + } + + if err := uc.mark(job, JobRunning, "download", 2, ""); err != nil { + return err + } + archiveBytes, err := uc.registry.Download(ctx, resolved.DownloadURL) + if err != nil { + return uc.fail(job, err, false) + } + + if err := uc.mark(job, JobRunning, "verify", 3, ""); err != nil { + return err + } + actualChecksum := sha256Hex(archiveBytes) + if !strings.EqualFold(actualChecksum, resolved.ChecksumSHA256) { + decision := PolicyDecision{Decision: DecisionBlock, Reason: "checksum mismatch", Reasons: []string{"checksum mismatch"}} + job.Decision = &decision + _ = uc.store.UpdateJob(job) + uc.audit("market.policy.decision", job, &decision, map[string]any{"checksum_expected": resolved.ChecksumSHA256, "checksum_actual": actualChecksum}) + uc.event("market.policy.decision", "error", decision.Reason, job, &decision) + return uc.fail(job, fmt.Errorf("checksum mismatch: expected %s, got %s", resolved.ChecksumSHA256, actualChecksum), true) + } + + if err := uc.mark(job, JobRunning, "install", 4, ""); err != nil { + return err + } + manifest, installedPath, err := uc.installArchive(job, resolved, archiveBytes) + if err != nil { + return uc.fail(job, err, true) + } + job.InstalledPath = installedPath + _ = uc.store.UpdateJob(job) + + if err := uc.mark(job, JobRunning, "receipt", 5, ""); err != nil { + return err + } + receipt := &InstallReceipt{ + ID: receiptID(resolved.ArtifactID, resolved.Version), + JobID: job.ID, + ArtifactID: resolved.ArtifactID, + Kind: resolved.Kind, + Name: resolved.Name, + Description: firstNonEmpty(manifest.Description, manifest.Summary), + Version: resolved.Version, + Source: SourceCloud, + SourceID: "registry", + InstalledPath: installedPath, + InstalledBy: firstNonEmpty(job.InstalledBy, "user"), + InstalledAt: time.Now().UTC().Format(time.RFC3339), + ChecksumSHA256: actualChecksum, + Permissions: append([]string(nil), resolved.Permissions...), + RiskLevel: resolved.RiskLevel, + TrustLevel: resolved.TrustLevel, + Compatibility: manifest.Compatibility, + Dependencies: resolved.Dependencies, + Decision: cloneDecision(job.Decision), + } + if err := uc.store.SaveReceipt(receipt); err != nil { + _ = os.RemoveAll(installedPath) + return uc.fail(job, fmt.Errorf("receipt: %w", err), true) + } + if job.Type == "upgrade" { + if _, err := uc.store.UpdateBindingsForArtifactReceipt(receipt.ArtifactID, receipt.ID, receipt.Version); err != nil { + _ = os.RemoveAll(installedPath) + return uc.fail(job, fmt.Errorf("binding upgrade: %w", err), true) + } + } + job.ReceiptID = receipt.ID + job.State = JobSucceeded + job.ProgressStep = "succeeded" + job.ProgressIndex = job.ProgressTotal + job.CompletedAt = time.Now().UTC().Format(time.RFC3339) + if err := uc.store.UpdateJob(job); err != nil { + return err + } + uc.audit("market.install.succeeded", job, job.Decision, map[string]any{"receipt_id": receipt.ID, "installed_path": installedPath}) + uc.event("market.install.succeeded", "success", "Marketplace install succeeded", job, job.Decision) + return nil +} + +func (uc *InstallUseCase) installArchive(job *InstallJob, resolved ResolvedPackage, archiveBytes []byte) (artifactManifest, string, error) { + stageDir, err := os.MkdirTemp("", "anyclaw-market-install-*") + if err != nil { + return artifactManifest{}, "", err + } + defer os.RemoveAll(stageDir) + if err := extractArchiveBytes(archiveBytes, resolved.DownloadURL, stageDir); err != nil { + return artifactManifest{}, "", err + } + manifest, err := readArtifactManifest(stageDir) + if err != nil { + return artifactManifest{}, "", err + } + if !strings.EqualFold(manifest.ID, resolved.ArtifactID) { + return artifactManifest{}, "", fmt.Errorf("manifest id mismatch: expected %s, got %s", resolved.ArtifactID, manifest.ID) + } + if manifest.Kind != resolved.Kind { + return artifactManifest{}, "", fmt.Errorf("manifest kind mismatch: expected %s, got %s", resolved.Kind, manifest.Kind) + } + if manifest.Version != resolved.Version { + return artifactManifest{}, "", fmt.Errorf("manifest version mismatch: expected %s, got %s", resolved.Version, manifest.Version) + } + finalDir := filepath.Join(uc.store.InstalledDir(), string(resolved.Kind), safeName(resolved.ArtifactID), safeName(resolved.Version)) + backupDir := finalDir + ".rollback" + _ = os.RemoveAll(backupDir) + if _, err := os.Stat(finalDir); err == nil { + if err := os.Rename(finalDir, backupDir); err != nil { + return artifactManifest{}, "", err + } + } + if err := os.MkdirAll(filepath.Dir(finalDir), 0o755); err != nil { + restoreInstallBackup(finalDir, backupDir) + return artifactManifest{}, "", err + } + if err := copyDir(stageDir, finalDir); err != nil { + _ = os.RemoveAll(finalDir) + restoreInstallBackup(finalDir, backupDir) + return artifactManifest{}, "", err + } + _ = os.RemoveAll(backupDir) + return manifest, finalDir, nil +} + +func (uc *InstallUseCase) mark(job *InstallJob, state JobState, step string, index int, msg string) error { + job.State = state + job.ProgressStep = step + job.ProgressIndex = index + if msg != "" { + job.Error = msg + } + return uc.store.UpdateJob(job) +} + +func (uc *InstallUseCase) fail(job *InstallJob, err error, rolledBack bool) error { + if rolledBack { + job.State = JobRolledBack + job.RolledBack = true + } else { + job.State = JobFailed + } + job.Error = err.Error() + job.CompletedAt = time.Now().UTC().Format(time.RFC3339) + _ = uc.store.UpdateJob(job) + uc.audit("market.install.failed", job, job.Decision, map[string]any{"error": err.Error(), "rolled_back": rolledBack}) + uc.event("market.install.failed", "error", err.Error(), job, job.Decision) + return err +} + +func (uc *InstallUseCase) audit(eventType string, job *InstallJob, decision *PolicyDecision, detail map[string]any) { + if uc == nil || uc.store == nil || job == nil { + return + } + event := MarketAuditEvent{ + Type: eventType, + ArtifactID: job.ArtifactID, + JobID: job.ID, + Actor: firstNonEmpty(job.InstalledBy, "user"), + Detail: detail, + } + if decision != nil { + event.Decision = string(decision.Decision) + event.Reason = decision.Reason + } + _ = uc.store.AppendAudit(event) +} + +func (uc *InstallUseCase) event(eventType, level, message string, job *InstallJob, decision *PolicyDecision) { + if uc == nil || uc.store == nil || job == nil { + return + } + payload := map[string]any{ + "state": job.State, + } + if decision != nil { + payload["decision"] = decision.Decision + payload["reason"] = decision.Reason + } + _ = uc.store.AppendEvent(MarketEvent{ + Type: eventType, + Level: level, + Message: message, + ArtifactID: job.ArtifactID, + JobID: job.ID, + Payload: payload, + }) +} + +func eventLevelForDecision(decision PolicyDecision) string { + switch decision.Decision { + case DecisionBlock: + return "error" + case DecisionAuto: + return "success" + default: + return "info" + } +} + +func cloneDecision(decision *PolicyDecision) *PolicyDecision { + if decision == nil { + return nil + } + copy := *decision + copy.Reasons = append([]string(nil), decision.Reasons...) + copy.Permissions = append([]string(nil), decision.Permissions...) + copy.HighRiskPermissions = append([]string(nil), decision.HighRiskPermissions...) + return © +} + +type artifactManifest struct { + ID string `json:"id"` + Kind ArtifactKind `json:"kind"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Summary string `json:"summary,omitempty"` + Version string `json:"version"` + Compatibility Compatibility `json:"compatibility,omitempty"` +} + +func readArtifactManifest(root string) (artifactManifest, error) { + data, err := os.ReadFile(filepath.Join(root, "anyclaw.artifact.json")) + if err != nil { + return artifactManifest{}, fmt.Errorf("manifest missing: %w", err) + } + var manifest artifactManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return artifactManifest{}, fmt.Errorf("manifest invalid: %w", err) + } + if manifest.ID == "" || manifest.Kind == "" || manifest.Version == "" { + return artifactManifest{}, fmt.Errorf("manifest requires id, kind, and version") + } + return manifest, nil +} + +func extractArchiveBytes(data []byte, rawURL string, destDir string) error { + if strings.HasSuffix(strings.ToLower(strings.TrimSpace(rawURL)), ".tar.gz") || strings.HasSuffix(strings.ToLower(strings.TrimSpace(rawURL)), ".tgz") { + return extractTarGzBytes(data, destDir) + } + return extractZipBytes(data, destDir) +} + +func extractZipBytes(data []byte, destDir string) error { + reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return err + } + for _, file := range reader.File { + if file.FileInfo().Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("zip symlinks are not supported: %s", file.Name) + } + targetPath := filepath.Join(destDir, file.Name) + if !pathWithinBase(destDir, targetPath) { + return fmt.Errorf("zip entry escapes destination: %s", file.Name) + } + if file.FileInfo().IsDir() { + if err := os.MkdirAll(targetPath, 0o755); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return err + } + src, err := file.Open() + if err != nil { + return err + } + dst, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, file.Mode()) + if err != nil { + src.Close() + return err + } + _, copyErr := io.Copy(dst, src) + src.Close() + dst.Close() + if copyErr != nil { + return copyErr + } + } + return nil +} + +func extractTarGzBytes(data []byte, destDir string) error { + gzr, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return err + } + defer gzr.Close() + tr := tar.NewReader(gzr) + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + targetPath := filepath.Join(destDir, header.Name) + if !pathWithinBase(destDir, targetPath) { + return fmt.Errorf("tar entry escapes destination: %s", header.Name) + } + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(targetPath, 0o755); err != nil { + return err + } + case tar.TypeReg, tar.TypeRegA: + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return err + } + dst, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(header.Mode)&0o777) + if err != nil { + return err + } + _, copyErr := io.Copy(dst, tr) + closeErr := dst.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + default: + return fmt.Errorf("unsupported tar entry type for %s", header.Name) + } + } + return nil +} + +func copyDir(srcDir, destDir string) error { + 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 !pathWithinBase(destDir, target) { + return fmt.Errorf("copy target escapes destination: %s", rel) + } + if info.IsDir() { + return os.MkdirAll(target, 0o755) + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + return copyFile(path, target, info.Mode()) + }) +} + +func copyFile(srcPath, destPath string, mode os.FileMode) error { + src, err := os.Open(srcPath) + if err != nil { + return err + } + defer src.Close() + dst, err := os.OpenFile(destPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) + if err != nil { + return err + } + _, copyErr := io.Copy(dst, src) + closeErr := dst.Close() + if copyErr != nil { + return copyErr + } + return closeErr +} + +func restoreInstallBackup(finalDir, backupDir string) { + if _, err := os.Stat(backupDir); err == nil { + _ = os.RemoveAll(finalDir) + _ = os.Rename(backupDir, finalDir) + } +} + +func pathWithinBase(baseDir, targetPath string) bool { + baseDir = filepath.Clean(baseDir) + targetPath = filepath.Clean(targetPath) + return targetPath == baseDir || strings.HasPrefix(targetPath, baseDir+string(os.PathSeparator)) +} + +func sha256Hex(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func receiptID(artifactID, version string) string { + return artifactID + "@" + version +} diff --git a/pkg/marketplace/installer_test.go b/pkg/marketplace/installer_test.go new file mode 100644 index 00000000..4ded89ed --- /dev/null +++ b/pkg/marketplace/installer_test.go @@ -0,0 +1,566 @@ +package marketplace + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestInstallUseCaseSuccessWritesReceipt(t *testing.T) { + archive := testArtifactArchive(t, "cloud.skill.release-notes", ArtifactKindSkill, "1.0.0") + registry := &fakeInstallRegistry{ + resolved: ResolvedPackage{ + ArtifactID: "cloud.skill.release-notes", + Version: "1.0.0", + DownloadURL: "memory://release-notes", + ChecksumSHA256: sha256Hex(archive), + Kind: ArtifactKindSkill, + Name: "Release Notes", + Permissions: []string{"fs.read"}, + RiskLevel: "low", + TrustLevel: "verified", + }, + archive: archive, + } + store := NewStore(t.TempDir()) + uc := NewInstallUseCase(store, registry) + job, reused, err := uc.Start(context.Background(), InstallRequest{ArtifactID: "cloud.skill.release-notes", InstalledBy: "user", UserConfirmed: true}) + if err != nil { + t.Fatal(err) + } + if reused { + t.Fatal("first install should not reuse a job") + } + if err := uc.Execute(context.Background(), job.ID); err != nil { + t.Fatal(err) + } + + done, err := store.GetJob(job.ID) + if err != nil { + t.Fatal(err) + } + if done.State != JobSucceeded { + t.Fatalf("job state = %s, want succeeded; error=%s", done.State, done.Error) + } + if done.ReceiptID == "" || done.InstalledPath == "" { + t.Fatalf("job missing receipt/path: %#v", done) + } + if _, err := os.Stat(filepath.Join(done.InstalledPath, "anyclaw.artifact.json")); err != nil { + t.Fatalf("expected installed manifest: %v", err) + } + + data, err := os.ReadFile(store.ReceiptPath(done.ReceiptID)) + if err != nil { + t.Fatal(err) + } + var receipt InstallReceipt + if err := json.Unmarshal(data, &receipt); err != nil { + t.Fatal(err) + } + if receipt.ArtifactID != "cloud.skill.release-notes" || receipt.Version != "1.0.0" { + t.Fatalf("unexpected receipt: %#v", receipt) + } + if receipt.Decision == nil || receipt.Decision.Decision != DecisionAsk { + t.Fatalf("expected ask decision in receipt, got %#v", receipt.Decision) + } + auditData, err := os.ReadFile(store.AuditPath()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(auditData), "market.policy.decision") || !strings.Contains(string(auditData), "market.install.succeeded") { + t.Fatalf("audit missing policy/success events: %s", string(auditData)) + } +} + +func TestInstallUseCaseChecksumMismatchRollsBack(t *testing.T) { + archive := testArtifactArchive(t, "cloud.cli.repo-health", ArtifactKindCLI, "1.0.0") + registry := &fakeInstallRegistry{ + resolved: ResolvedPackage{ + ArtifactID: "cloud.cli.repo-health", + Version: "1.0.0", + DownloadURL: "memory://repo-health", + ChecksumSHA256: "not-the-real-checksum", + Kind: ArtifactKindCLI, + Name: "Repo Health", + }, + archive: archive, + } + store := NewStore(t.TempDir()) + uc := NewInstallUseCase(store, registry) + job, _, err := uc.Start(context.Background(), InstallRequest{ArtifactID: "cloud.cli.repo-health", UserConfirmed: true}) + if err != nil { + t.Fatal(err) + } + if err := uc.Execute(context.Background(), job.ID); err == nil { + t.Fatal("expected checksum mismatch error") + } + + done, err := store.GetJob(job.ID) + if err != nil { + t.Fatal(err) + } + if done.State != JobRolledBack || !done.RolledBack { + t.Fatalf("job state = %s rolled_back=%v, want rolled_back", done.State, done.RolledBack) + } + if _, err := os.Stat(filepath.Join(store.InstalledDir(), "cli", "cloud-cli-repo-health", "1-0-0")); !os.IsNotExist(err) { + t.Fatalf("expected no installed dir after rollback, stat err=%v", err) + } +} + +func TestInstallUseCaseRejectsManifestVersionMismatch(t *testing.T) { + archive := testArtifactArchive(t, "cloud.skill.release-notes", ArtifactKindSkill, "2.0.0") + registry := &fakeInstallRegistry{ + resolved: ResolvedPackage{ + ArtifactID: "cloud.skill.release-notes", + Version: "1.0.0", + DownloadURL: "memory://release-notes", + ChecksumSHA256: sha256Hex(archive), + Kind: ArtifactKindSkill, + Name: "Release Notes", + RiskLevel: "low", + TrustLevel: "verified", + }, + archive: archive, + } + store := NewStore(t.TempDir()) + uc := NewInstallUseCase(store, registry) + job, _, err := uc.Start(context.Background(), InstallRequest{ArtifactID: "cloud.skill.release-notes", UserConfirmed: true}) + if err != nil { + t.Fatal(err) + } + err = uc.Execute(context.Background(), job.ID) + if err == nil || !strings.Contains(err.Error(), "manifest version mismatch") { + t.Fatalf("expected manifest version mismatch, got %v", err) + } + done, err := store.GetJob(job.ID) + if err != nil { + t.Fatal(err) + } + if done.State != JobRolledBack { + t.Fatalf("job state = %s, want rolled_back", done.State) + } + if _, err := os.Stat(filepath.Join(store.InstalledDir(), "skill", "cloud-skill-release-notes", "1-0-0")); !os.IsNotExist(err) { + t.Fatalf("expected no installed dir after mismatch, stat err=%v", err) + } +} + +func TestInstallUseCaseMissingChecksumBlocksBeforeDownload(t *testing.T) { + registry := &fakeInstallRegistry{ + resolved: ResolvedPackage{ + ArtifactID: "cloud.cli.repo-health", + Version: "1.0.0", + DownloadURL: "memory://repo-health", + Kind: ArtifactKindCLI, + Name: "Repo Health", + RiskLevel: "medium", + TrustLevel: "verified", + }, + archive: testArtifactArchive(t, "cloud.cli.repo-health", ArtifactKindCLI, "1.0.0"), + } + store := NewStore(t.TempDir()) + uc := NewInstallUseCase(store, registry) + job, _, err := uc.Start(context.Background(), InstallRequest{ArtifactID: "cloud.cli.repo-health", UserConfirmed: true}) + if err != nil { + t.Fatal(err) + } + if err := uc.Execute(context.Background(), job.ID); err == nil { + t.Fatal("expected missing checksum block") + } + if registry.downloads != 0 { + t.Fatalf("download count = %d, want 0", registry.downloads) + } + done, err := store.GetJob(job.ID) + if err != nil { + t.Fatal(err) + } + if done.State != JobFailed || done.Decision == nil || done.Decision.Reason != "missing checksum" { + t.Fatalf("job = %#v, want missing checksum block", done) + } +} + +func TestInstallUseCasePolicyBlocksBeforeDownload(t *testing.T) { + registry := &fakeInstallRegistry{ + resolved: ResolvedPackage{ + ArtifactID: "cloud.cli.danger", + Version: "1.0.0", + DownloadURL: "memory://danger", + Kind: ArtifactKindCLI, + Name: "Danger", + RiskLevel: "high", + TrustLevel: "verified", + Permissions: []string{"process.exec"}, + }, + archive: testArtifactArchive(t, "cloud.cli.danger", ArtifactKindCLI, "1.0.0"), + } + store := NewStore(t.TempDir()) + uc := NewInstallUseCase(store, registry) + job, _, err := uc.Start(context.Background(), InstallRequest{ArtifactID: "cloud.cli.danger", UserConfirmed: true}) + if err != nil { + t.Fatal(err) + } + if err := uc.Execute(context.Background(), job.ID); err == nil { + t.Fatal("expected policy block") + } + if registry.downloads != 0 { + t.Fatalf("download count = %d, want 0", registry.downloads) + } + done, err := store.GetJob(job.ID) + if err != nil { + t.Fatal(err) + } + if done.State != JobFailed || done.Decision == nil || done.Decision.Decision != DecisionBlock { + t.Fatalf("job = %#v, want failed blocked job", done) + } +} + +func TestInstallUseCasePolicyHighRiskPermissionRequiresAcknowledgementBeforeDownload(t *testing.T) { + registry := &fakeInstallRegistry{ + resolved: ResolvedPackage{ + ArtifactID: "cloud.skill.shell-helper", + Version: "1.0.0", + DownloadURL: "memory://shell-helper", + ChecksumSHA256: "unused-before-download", + Kind: ArtifactKindSkill, + Name: "Shell Helper", + RiskLevel: "low", + TrustLevel: "verified", + Permissions: []string{"fs.read", "process.exec"}, + }, + archive: testArtifactArchive(t, "cloud.skill.shell-helper", ArtifactKindSkill, "1.0.0"), + } + store := NewStore(t.TempDir()) + uc := NewInstallUseCaseWithPolicy(store, registry, PolicyConfig{AutoInstallSkill: true}) + job, _, err := uc.Start(context.Background(), InstallRequest{ArtifactID: "cloud.skill.shell-helper", UserConfirmed: true}) + if err != nil { + t.Fatal(err) + } + if err := uc.Execute(context.Background(), job.ID); err == nil { + t.Fatal("expected high-risk acknowledgement requirement") + } + if registry.downloads != 0 { + t.Fatalf("download count = %d, want 0", registry.downloads) + } + done, err := store.GetJob(job.ID) + if err != nil { + t.Fatal(err) + } + if done.State != JobFailed || done.Decision == nil || !done.Decision.RequiresRiskAcknowledgement { + t.Fatalf("job = %#v, want failed job requiring risk acknowledgement", done) + } + if len(done.Decision.HighRiskPermissions) != 1 || done.Decision.HighRiskPermissions[0] != "process.exec" { + t.Fatalf("high risk permissions = %#v", done.Decision.HighRiskPermissions) + } +} + +func TestInstallUseCaseIdempotencyReusesJob(t *testing.T) { + store := NewStore(t.TempDir()) + uc := NewInstallUseCase(store, &fakeInstallRegistry{}) + first, reused, err := uc.Start(context.Background(), InstallRequest{ + ArtifactID: "cloud.agent.code-reviewer", + IdempotencyKey: "idem-1", + }) + if err != nil { + t.Fatal(err) + } + if reused { + t.Fatal("first request should not be reused") + } + second, reused, err := uc.Start(context.Background(), InstallRequest{ + ArtifactID: "cloud.agent.code-reviewer", + IdempotencyKey: "idem-1", + }) + if err != nil { + t.Fatal(err) + } + if !reused { + t.Fatal("second request should reuse existing job") + } + if second.ID != first.ID { + t.Fatalf("expected same job id, got %s and %s", first.ID, second.ID) + } +} + +func TestUpgradeUseCaseKeepsBindingsAndMovesThemToNewReceipt(t *testing.T) { + oldArchive := testArtifactArchive(t, "cloud.skill.release-notes", ArtifactKindSkill, "1.0.0") + newArchive := testArtifactArchive(t, "cloud.skill.release-notes", ArtifactKindSkill, "2.0.0") + store := NewStore(t.TempDir()) + oldPath := filepath.Join(store.InstalledDir(), "skill", "cloud-skill-release-notes", "1-0-0") + if err := os.MkdirAll(oldPath, 0o755); err != nil { + t.Fatal(err) + } + oldReceipt := &InstallReceipt{ + ID: "cloud.skill.release-notes@1.0.0", + ArtifactID: "cloud.skill.release-notes", + Kind: ArtifactKindSkill, + Name: "Release Notes", + Version: "1.0.0", + Source: SourceCloud, + InstalledPath: oldPath, + InstalledBy: "user", + InstalledAt: time.Now().Add(-time.Hour).UTC().Format(time.RFC3339), + ChecksumSHA256: sha256Hex(oldArchive), + } + if err := store.SaveReceipt(oldReceipt); err != nil { + t.Fatal(err) + } + binding, err := store.CreateBinding(BindingRequest{ArtifactID: oldReceipt.ArtifactID, ReceiptID: oldReceipt.ID, TargetType: TargetRuntimeGlobal}) + if err != nil { + t.Fatal(err) + } + registry := &fakeInstallRegistry{ + resolved: ResolvedPackage{ + ArtifactID: oldReceipt.ArtifactID, + Version: "2.0.0", + DownloadURL: "memory://release-notes-2", + ChecksumSHA256: sha256Hex(newArchive), + Kind: ArtifactKindSkill, + Name: "Release Notes", + RiskLevel: "low", + TrustLevel: "verified", + Permissions: []string{"fs.read"}, + }, + archive: newArchive, + } + job, reused, err := store.CreateUpgradeJob(UpgradeRequest{ArtifactID: oldReceipt.ArtifactID, VersionConstraint: "2.0.0", UserConfirmed: true}, "") + if err != nil { + t.Fatal(err) + } + if reused { + t.Fatal("upgrade should not reuse job") + } + uc := NewInstallUseCase(store, registry) + if err := uc.Execute(context.Background(), job.ID); err != nil { + t.Fatal(err) + } + done, err := store.GetJob(job.ID) + if err != nil { + t.Fatal(err) + } + if done.State != JobSucceeded || done.Type != "upgrade" { + t.Fatalf("job = %#v, want succeeded upgrade", done) + } + bindings, err := store.ListBindings() + if err != nil { + t.Fatal(err) + } + if len(bindings.Items) != 1 || bindings.Items[0].ID != binding.ID || bindings.Items[0].Version != "2.0.0" || bindings.Items[0].ReceiptID != "cloud.skill.release-notes@2.0.0" { + t.Fatalf("bindings = %#v, want upgraded binding", bindings.Items) + } +} + +func TestUpgradeChecksumFailureKeepsPreviousReceiptAndBinding(t *testing.T) { + oldArchive := testArtifactArchive(t, "cloud.cli.repo-health", ArtifactKindCLI, "1.0.0") + newArchive := testArtifactArchive(t, "cloud.cli.repo-health", ArtifactKindCLI, "2.0.0") + store := NewStore(t.TempDir()) + oldReceipt := &InstallReceipt{ + ID: "cloud.cli.repo-health@1.0.0", + ArtifactID: "cloud.cli.repo-health", + Kind: ArtifactKindCLI, + Name: "Repo Health", + Version: "1.0.0", + Source: SourceCloud, + InstalledPath: filepath.Join(store.InstalledDir(), "cli", "cloud-cli-repo-health", "1-0-0"), + InstalledBy: "user", + InstalledAt: time.Now().Add(-time.Hour).UTC().Format(time.RFC3339), + ChecksumSHA256: sha256Hex(oldArchive), + } + if err := os.MkdirAll(oldReceipt.InstalledPath, 0o755); err != nil { + t.Fatal(err) + } + if err := store.SaveReceipt(oldReceipt); err != nil { + t.Fatal(err) + } + if _, err := store.CreateBinding(BindingRequest{ArtifactID: oldReceipt.ArtifactID, ReceiptID: oldReceipt.ID, TargetType: TargetRuntimeGlobal}); err != nil { + t.Fatal(err) + } + registry := &fakeInstallRegistry{ + resolved: ResolvedPackage{ + ArtifactID: oldReceipt.ArtifactID, + Version: "2.0.0", + DownloadURL: "memory://repo-health-2", + ChecksumSHA256: "bad-checksum", + Kind: ArtifactKindCLI, + Name: "Repo Health", + RiskLevel: "medium", + TrustLevel: "verified", + Permissions: []string{"process.exec"}, + }, + archive: newArchive, + } + job, _, err := store.CreateUpgradeJob(UpgradeRequest{ArtifactID: oldReceipt.ArtifactID, VersionConstraint: "2.0.0", UserConfirmed: true}, "") + if err != nil { + t.Fatal(err) + } + if err := NewInstallUseCase(store, registry).Execute(context.Background(), job.ID); err == nil { + t.Fatal("expected checksum failure") + } + if _, err := store.GetReceipt(oldReceipt.ID); err != nil { + t.Fatalf("old receipt should remain: %v", err) + } + bindings, err := store.ListBindings() + if err != nil { + t.Fatal(err) + } + if len(bindings.Items) != 1 || bindings.Items[0].Version != "1.0.0" || bindings.Items[0].ReceiptID != oldReceipt.ID { + t.Fatalf("bindings = %#v, want previous binding", bindings.Items) + } +} + +func TestInstallUseCaseStartAndExecuteValidation(t *testing.T) { + if _, _, err := (*InstallUseCase)(nil).Start(context.Background(), InstallRequest{ArtifactID: "x"}); err == nil { + t.Fatal("expected nil use case start error") + } + uc := NewInstallUseCase(NewStore(t.TempDir()), &fakeInstallRegistry{}) + if _, _, err := uc.Start(context.Background(), InstallRequest{}); err == nil || !strings.Contains(err.Error(), "artifact_id is required") { + t.Fatalf("expected artifact_id error, got %v", err) + } + if err := (*InstallUseCase)(nil).Execute(context.Background(), "job-1"); err == nil { + t.Fatal("expected nil use case execute error") + } + job, _, err := uc.Start(context.Background(), InstallRequest{ArtifactID: "cloud.skill.done"}) + if err != nil { + t.Fatal(err) + } + job.State = JobSucceeded + if err := uc.store.UpdateJob(job); err != nil { + t.Fatal(err) + } + if err := uc.Execute(context.Background(), job.ID); err != nil { + t.Fatalf("terminal job should no-op: %v", err) + } +} + +func TestArchiveExtractionRejectsUnsafeEntriesAndSupportsTarGz(t *testing.T) { + dest := t.TempDir() + if err := extractArchiveBytes(testTarGzArchive(t, map[string]string{"dir/file.txt": "ok"}), "https://example.test/pkg.tgz", dest); err != nil { + t.Fatalf("extract tar.gz: %v", err) + } + if data, err := os.ReadFile(filepath.Join(dest, "dir", "file.txt")); err != nil || string(data) != "ok" { + t.Fatalf("unexpected tar extract data=%q err=%v", data, err) + } + if err := extractArchiveBytes(testZipArchiveWithEntry(t, "../escape.txt", "bad"), "https://example.test/pkg.zip", t.TempDir()); err == nil { + t.Fatal("expected zip escape rejection") + } + if err := extractArchiveBytes(testTarGzArchive(t, map[string]string{"../escape.txt": "bad"}), "https://example.test/pkg.tar.gz", t.TempDir()); err == nil { + t.Fatal("expected tar escape rejection") + } +} + +func TestInstallerHelpers(t *testing.T) { + decision := &PolicyDecision{ + Decision: DecisionAsk, + Reasons: []string{"confirm"}, + Permissions: []string{"fs.read"}, + HighRiskPermissions: []string{"process.exec"}, + } + cloned := cloneDecision(decision) + cloned.Reasons[0] = "changed" + if decision.Reasons[0] != "confirm" { + t.Fatal("cloneDecision should deep-copy slices") + } + if eventLevelForDecision(PolicyDecision{Decision: DecisionBlock}) != "error" || + eventLevelForDecision(PolicyDecision{Decision: DecisionAuto}) != "success" || + eventLevelForDecision(PolicyDecision{Decision: DecisionAsk}) != "info" { + t.Fatal("unexpected decision levels") + } + if receiptID("artifact", "1.0.0") != "artifact@1.0.0" { + t.Fatal("receiptID mismatch") + } + base := t.TempDir() + if !pathWithinBase(base, filepath.Join(base, "child")) || pathWithinBase(base, filepath.Join(base, "..", "other")) { + t.Fatal("pathWithinBase mismatch") + } +} + +type fakeInstallRegistry struct { + resolved ResolvedPackage + archive []byte + downloads int +} + +func (f *fakeInstallRegistry) Resolve(context.Context, string, string) (ResolvedPackage, error) { + return f.resolved, nil +} + +func (f *fakeInstallRegistry) Download(context.Context, string) ([]byte, error) { + f.downloads++ + return append([]byte(nil), f.archive...), nil +} + +func testArtifactArchive(t *testing.T, id string, kind ArtifactKind, version string) []byte { + t.Helper() + var buf bytes.Buffer + writer := zip.NewWriter(&buf) + manifest := map[string]any{ + "id": id, + "kind": kind, + "name": id, + "version": version, + } + w, err := writer.Create("anyclaw.artifact.json") + if err != nil { + t.Fatal(err) + } + data, _ := json.Marshal(manifest) + if _, err := w.Write(data); err != nil { + t.Fatal(err) + } + w, err = writer.Create("README.md") + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte("fixture")); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func testZipArchiveWithEntry(t *testing.T, name string, content string) []byte { + t.Helper() + var buf bytes.Buffer + writer := zip.NewWriter(&buf) + w, err := writer.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(content)); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func testTarGzArchive(t *testing.T, files map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + gzw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gzw) + for name, content := range files { + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(content))}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gzw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} diff --git a/pkg/marketplace/lifecycle.go b/pkg/marketplace/lifecycle.go new file mode 100644 index 00000000..2a8551db --- /dev/null +++ b/pkg/marketplace/lifecycle.go @@ -0,0 +1,127 @@ +package marketplace + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +type LifecycleService struct { + store *Store +} + +func NewLifecycleService(store *Store) *LifecycleService { + return &LifecycleService{store: store} +} + +func (s *LifecycleService) Uninstall(req UninstallRequest) (*UninstallResult, error) { + if s == nil || s.store == nil { + return nil, fmt.Errorf("marketplace lifecycle store is not configured") + } + artifactID := strings.TrimSpace(req.ArtifactID) + var receipt *InstallReceipt + var err error + if strings.TrimSpace(req.ReceiptID) != "" { + receipt, err = s.store.GetReceipt(strings.TrimSpace(req.ReceiptID)) + } else { + receipt, err = s.store.LatestReceiptForArtifact(artifactID) + } + if err != nil { + return nil, err + } + if artifactID == "" { + artifactID = receipt.ArtifactID + } + if !strings.EqualFold(strings.TrimSpace(receipt.ArtifactID), artifactID) { + return nil, fmt.Errorf("receipt does not belong to artifact") + } + + removedBindings, err := s.store.DeleteBindingsForArtifact(receipt.ArtifactID) + if err != nil { + return nil, err + } + if strings.TrimSpace(receipt.InstalledPath) != "" { + installedPath, err := safeInstalledPath(s.store.InstalledDir(), receipt.InstalledPath) + if err != nil { + return nil, err + } + if err := os.RemoveAll(installedPath); err != nil { + return nil, err + } + } + if err := s.store.DeleteReceipt(receipt.ID); err != nil { + return nil, err + } + + bindingIDs := make([]string, 0, len(removedBindings)) + for _, binding := range removedBindings { + bindingIDs = append(bindingIDs, binding.ID) + } + result := &UninstallResult{ + ArtifactID: receipt.ArtifactID, + ReceiptID: receipt.ID, + RemovedBindings: bindingIDs, + RemovedPath: receipt.InstalledPath, + UninstalledAt: time.Now().UTC().Format(time.RFC3339), + PreviousVersion: receipt.Version, + UndoAvailableSec: 30, + } + s.audit("market.uninstall.succeeded", req.Actor, result, map[string]any{ + "removed_bindings": bindingIDs, + "removed_path": receipt.InstalledPath, + "version": receipt.Version, + }) + s.event("market.uninstall.succeeded", "success", "Marketplace artifact uninstalled", result, nil) + return result, nil +} + +func safeInstalledPath(installedRoot, installedPath string) (string, error) { + root, err := filepath.Abs(filepath.Clean(installedRoot)) + if err != nil { + return "", err + } + target, err := filepath.Abs(filepath.Clean(strings.TrimSpace(installedPath))) + if err != nil { + return "", err + } + rel, err := filepath.Rel(root, target) + if err != nil { + return "", err + } + if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) { + return "", fmt.Errorf("installed path escapes marketplace install root") + } + return target, nil +} + +func (s *LifecycleService) audit(eventType, actor string, result *UninstallResult, detail map[string]any) { + if s == nil || s.store == nil || result == nil { + return + } + _ = s.store.AppendAudit(MarketAuditEvent{ + Type: eventType, + ArtifactID: result.ArtifactID, + Actor: firstNonEmpty(actor, "user"), + Detail: detail, + }) +} + +func (s *LifecycleService) event(eventType, level, message string, result *UninstallResult, payload map[string]any) { + if s == nil || s.store == nil || result == nil { + return + } + if payload == nil { + payload = map[string]any{} + } + payload["receipt_id"] = result.ReceiptID + payload["removed_bindings"] = result.RemovedBindings + _ = s.store.AppendEvent(MarketEvent{ + Type: eventType, + Level: level, + Message: message, + ArtifactID: result.ArtifactID, + Payload: payload, + }) +} diff --git a/pkg/marketplace/lifecycle_test.go b/pkg/marketplace/lifecycle_test.go new file mode 100644 index 00000000..823b5123 --- /dev/null +++ b/pkg/marketplace/lifecycle_test.go @@ -0,0 +1,102 @@ +package marketplace + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLifecycleUninstallRemovesReceiptBindingsAndInstallDir(t *testing.T) { + store := NewStore(t.TempDir()) + installedPath := filepath.Join(store.InstalledDir(), "skill", "cloud-skill-release-notes", "1-0-0") + if err := os.MkdirAll(installedPath, 0o755); err != nil { + t.Fatal(err) + } + receipt := &InstallReceipt{ + ID: "cloud.skill.release-notes@1.0.0", + ArtifactID: "cloud.skill.release-notes", + Kind: ArtifactKindSkill, + Name: "Release Notes", + Version: "1.0.0", + Source: SourceCloud, + InstalledPath: installedPath, + InstalledBy: "user", + InstalledAt: time.Now().UTC().Format(time.RFC3339), + } + if err := store.SaveReceipt(receipt); err != nil { + t.Fatal(err) + } + binding, err := store.CreateBinding(BindingRequest{ + ArtifactID: receipt.ArtifactID, + TargetType: TargetRuntimeGlobal, + TargetID: "", + ReceiptID: receipt.ID, + }) + if err != nil { + t.Fatal(err) + } + + result, err := NewLifecycleService(store).Uninstall(UninstallRequest{ArtifactID: receipt.ArtifactID, Actor: "tester"}) + if err != nil { + t.Fatal(err) + } + if result.ReceiptID != receipt.ID || len(result.RemovedBindings) != 1 || result.RemovedBindings[0] != binding.ID { + t.Fatalf("unexpected uninstall result: %#v", result) + } + if _, err := os.Stat(installedPath); !os.IsNotExist(err) { + t.Fatalf("installed path still exists or stat failed unexpectedly: %v", err) + } + if _, err := store.GetReceipt(receipt.ID); err != ErrArtifactNotFound { + t.Fatalf("receipt err = %v, want ErrArtifactNotFound", err) + } + bindings, err := store.ListBindings() + if err != nil { + t.Fatal(err) + } + if len(bindings.Items) != 0 { + t.Fatalf("bindings = %#v, want empty", bindings.Items) + } + auditData, err := os.ReadFile(store.AuditPath()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(auditData), "market.uninstall.succeeded") { + t.Fatalf("audit missing uninstall event: %s", string(auditData)) + } +} + +func TestLifecycleUninstallRejectsInstalledPathOutsideInstallRoot(t *testing.T) { + root := t.TempDir() + store := NewStore(root) + outsidePath := filepath.Join(root, "outside") + if err := os.MkdirAll(outsidePath, 0o755); err != nil { + t.Fatal(err) + } + receipt := &InstallReceipt{ + ID: "cloud.skill.release-notes@1.0.0", + ArtifactID: "cloud.skill.release-notes", + Kind: ArtifactKindSkill, + Name: "Release Notes", + Version: "1.0.0", + Source: SourceCloud, + InstalledPath: outsidePath, + InstalledBy: "user", + InstalledAt: time.Now().UTC().Format(time.RFC3339), + } + if err := store.SaveReceipt(receipt); err != nil { + t.Fatal(err) + } + + _, err := NewLifecycleService(store).Uninstall(UninstallRequest{ArtifactID: receipt.ArtifactID}) + if err == nil || !strings.Contains(err.Error(), "escapes marketplace install root") { + t.Fatalf("expected install root escape error, got %v", err) + } + if _, err := os.Stat(outsidePath); err != nil { + t.Fatalf("outside path should remain: %v", err) + } + if _, err := store.GetReceipt(receipt.ID); err != nil { + t.Fatalf("receipt should remain: %v", err) + } +} diff --git a/pkg/marketplace/policy.go b/pkg/marketplace/policy.go new file mode 100644 index 00000000..f695de12 --- /dev/null +++ b/pkg/marketplace/policy.go @@ -0,0 +1,182 @@ +package marketplace + +import ( + "fmt" + "runtime" + "strings" +) + +type PolicyConfig struct { + AutoInstallSkill bool +} + +type DecisionPolicy struct { + cfg PolicyConfig +} + +func NewDecisionPolicy(cfg PolicyConfig) DecisionPolicy { + return DecisionPolicy{cfg: cfg} +} + +func (p DecisionPolicy) DecideInstall(req InstallRequest, resolved ResolvedPackage) PolicyDecision { + risk := normalizedRisk(resolved.RiskLevel) + trust := normalizedTrust(resolved.TrustLevel) + highRiskPermissions := highRiskPermissions(resolved.Permissions) + + reasons := policyBlockReasons(resolved) + if len(reasons) > 0 { + return PolicyDecision{ + Decision: DecisionBlock, + Reason: strings.Join(reasons, "; "), + Reasons: reasons, + RiskLevel: risk, + TrustLevel: trust, + Permissions: append([]string(nil), resolved.Permissions...), + HighRiskPermissions: highRiskPermissions, + } + } + + if p.cfg.AutoInstallSkill && resolved.Kind == ArtifactKindSkill && risk == "low" && trust == "verified" && len(highRiskPermissions) == 0 { + return PolicyDecision{ + Decision: DecisionAuto, + Reason: "low-risk verified skill auto install is enabled", + Reasons: []string{"low-risk verified skill auto install is enabled"}, + RiskLevel: risk, + TrustLevel: trust, + Permissions: append([]string(nil), resolved.Permissions...), + } + } + + reasons = installRiskReasons(resolved, highRiskPermissions) + reason := strings.Join(reasons, "; ") + if reason == "" { + reason = fmt.Sprintf("%s artifacts require user confirmation", resolved.Kind) + reasons = []string{reason} + } + if req.UserConfirmed { + reasons = append(reasons, "user confirmed marketplace install") + reason = strings.Join(reasons, "; ") + } + requiresRiskAcknowledgement := len(highRiskPermissions) > 0 && !req.RiskAcknowledged + if req.RiskAcknowledged && len(highRiskPermissions) > 0 { + reasons = append(reasons, "user acknowledged high-risk permissions") + reason = strings.Join(reasons, "; ") + } + return PolicyDecision{ + Decision: DecisionAsk, + Reason: reason, + Reasons: appendUniqueStrings(nil, reasons...), + RequiresUserConfirmation: !req.UserConfirmed, + RequiresRiskAcknowledgement: requiresRiskAcknowledgement, + RiskLevel: risk, + TrustLevel: trust, + Permissions: append([]string(nil), resolved.Permissions...), + HighRiskPermissions: highRiskPermissions, + } +} + +func policyBlockReasons(resolved ResolvedPackage) []string { + var reasons []string + risk := normalizedRisk(resolved.RiskLevel) + if risk == "high" { + reasons = append(reasons, "high-risk artifacts are blocked") + } + if normalizedTrust(resolved.TrustLevel) == "quarantined" { + reasons = append(reasons, "quarantined artifacts are blocked") + } + for _, permission := range resolved.Permissions { + if highRiskPermission(permission) && risk == "high" { + reasons = append(reasons, "high-risk permission "+strings.TrimSpace(permission)+" is blocked") + } + } + if !compatibleWithCurrentRuntime(resolved.Compatibility) { + reasons = append(reasons, "artifact is incompatible with this OS or architecture") + } + return appendUniqueStrings(nil, reasons...) +} + +func installRiskReasons(resolved ResolvedPackage, highRiskPermissions []string) []string { + reasons := []string{fmt.Sprintf("%s artifacts require user confirmation", resolved.Kind)} + if risk := normalizedRisk(resolved.RiskLevel); risk != "" { + reasons = append(reasons, "risk level: "+risk) + } + if trust := normalizedTrust(resolved.TrustLevel); trust != "" { + reasons = append(reasons, "trust level: "+trust) + } + if len(highRiskPermissions) > 0 { + reasons = append(reasons, "high-risk permissions require explicit acknowledgement: "+strings.Join(highRiskPermissions, ", ")) + } + return appendUniqueStrings(nil, reasons...) +} + +func highRiskPermissions(values []string) []string { + var permissions []string + for _, permission := range values { + if highRiskPermission(permission) { + permissions = append(permissions, strings.TrimSpace(permission)) + } + } + return appendUniqueStrings(nil, permissions...) +} + +func normalizedRisk(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "medium" + } + return value +} + +func normalizedTrust(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func highRiskPermission(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "process.exec", "process.kill", "desktop.control", "browser.control", "network.any", "secrets.read", "fs.delete": + return true + default: + return false + } +} + +func compatibleWithCurrentRuntime(compat Compatibility) bool { + if len(compat.OS) > 0 && !containsNormalized(compat.OS, runtime.GOOS) { + return false + } + if len(compat.Arch) > 0 && !containsNormalized(compat.Arch, runtime.GOARCH) { + return false + } + return true +} + +func containsNormalized(values []string, want string) bool { + want = strings.ToLower(strings.TrimSpace(want)) + for _, value := range values { + if strings.ToLower(strings.TrimSpace(value)) == want { + return true + } + } + return false +} + +func appendUniqueStrings(base []string, values ...string) []string { + out := append([]string(nil), base...) + seen := make(map[string]bool, len(out)+len(values)) + for _, value := range out { + seen[strings.ToLower(strings.TrimSpace(value))] = true + } + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + key := strings.ToLower(trimmed) + if seen[key] { + continue + } + seen[key] = true + out = append(out, trimmed) + } + return out +} diff --git a/pkg/marketplace/policy_test.go b/pkg/marketplace/policy_test.go new file mode 100644 index 00000000..d1892c5d --- /dev/null +++ b/pkg/marketplace/policy_test.go @@ -0,0 +1,81 @@ +package marketplace + +import "testing" + +func TestDecisionPolicyBlocksHighRiskAndQuarantined(t *testing.T) { + policy := NewDecisionPolicy(PolicyConfig{AutoInstallSkill: true}) + decision := policy.DecideInstall(InstallRequest{UserConfirmed: true}, ResolvedPackage{ + ArtifactID: "cloud.cli.danger", + Kind: ArtifactKindCLI, + RiskLevel: "high", + TrustLevel: "verified", + Permissions: []string{"process.exec"}, + }) + if decision.Decision != DecisionBlock { + t.Fatalf("decision = %s, want block", decision.Decision) + } + + decision = policy.DecideInstall(InstallRequest{UserConfirmed: true}, ResolvedPackage{ + ArtifactID: "cloud.skill.quarantined", + Kind: ArtifactKindSkill, + RiskLevel: "low", + TrustLevel: "quarantined", + }) + if decision.Decision != DecisionBlock { + t.Fatalf("quarantined decision = %s, want block", decision.Decision) + } +} + +func TestDecisionPolicyAutoInstallSkillRequiresConfigLowRiskAndVerified(t *testing.T) { + resolved := ResolvedPackage{ + ArtifactID: "cloud.skill.release-notes", + Kind: ArtifactKindSkill, + RiskLevel: "low", + TrustLevel: "verified", + } + decision := NewDecisionPolicy(PolicyConfig{AutoInstallSkill: true}).DecideInstall(InstallRequest{}, resolved) + if decision.Decision != DecisionAuto { + t.Fatalf("decision = %s, want auto", decision.Decision) + } + + decision = NewDecisionPolicy(PolicyConfig{}).DecideInstall(InstallRequest{}, resolved) + if decision.Decision != DecisionAsk || !decision.RequiresUserConfirmation { + t.Fatalf("decision = %#v, want ask requiring confirmation", decision) + } +} + +func TestDecisionPolicyHighRiskPermissionRequiresExplicitAcknowledgement(t *testing.T) { + resolved := ResolvedPackage{ + ArtifactID: "cloud.skill.shell-helper", + Kind: ArtifactKindSkill, + RiskLevel: "low", + TrustLevel: "verified", + Permissions: []string{"fs.read", "process.exec"}, + } + policy := NewDecisionPolicy(PolicyConfig{AutoInstallSkill: true}) + + decision := policy.DecideInstall(InstallRequest{UserConfirmed: true}, resolved) + if decision.Decision != DecisionAsk || !decision.RequiresRiskAcknowledgement { + t.Fatalf("decision = %#v, want ask requiring high-risk acknowledgement", decision) + } + if len(decision.HighRiskPermissions) != 1 || decision.HighRiskPermissions[0] != "process.exec" { + t.Fatalf("high risk permissions = %#v", decision.HighRiskPermissions) + } + + decision = policy.DecideInstall(InstallRequest{UserConfirmed: true, RiskAcknowledged: true}, resolved) + if decision.Decision != DecisionAsk || decision.RequiresRiskAcknowledgement || decision.RequiresUserConfirmation { + t.Fatalf("decision = %#v, want acknowledged ask", decision) + } +} + +func TestDecisionPolicyAskIsSatisfiedByUserConfirmation(t *testing.T) { + decision := NewDecisionPolicy(PolicyConfig{}).DecideInstall(InstallRequest{UserConfirmed: true}, ResolvedPackage{ + ArtifactID: "cloud.agent.reviewer", + Kind: ArtifactKindAgent, + RiskLevel: "medium", + TrustLevel: "verified", + }) + if decision.Decision != DecisionAsk || decision.RequiresUserConfirmation { + t.Fatalf("decision = %#v, want confirmed ask", decision) + } +} diff --git a/pkg/marketplace/registry/client.go b/pkg/marketplace/registry/client.go new file mode 100644 index 00000000..ab01c217 --- /dev/null +++ b/pkg/marketplace/registry/client.go @@ -0,0 +1,375 @@ +package registry + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/1024XEngineer/anyclaw/pkg/config" + "github.com/1024XEngineer/anyclaw/pkg/marketplace" +) + +var ( + ErrRemoteDisabled = errors.New("marketplace remote registry is disabled") + ErrNotConfigured = errors.New("marketplace registry endpoint is not configured") +) + +type Client struct { + endpoint string + token string + protocolVersion string + httpClient *http.Client + downloadClient *http.Client + retryCount int + cacheTTL time.Duration + + mu sync.Mutex + cache map[string]cacheEntry +} + +type ClientConfig struct { + Endpoint string + Token string + ProtocolVersion string + Timeout time.Duration + DownloadTimeout time.Duration + RetryCount int + CacheTTL time.Duration +} + +type cacheEntry struct { + expiresAt time.Time + data []byte +} + +func NewClient(cfg ClientConfig) *Client { + timeout := cfg.Timeout + if timeout <= 0 { + timeout = 30 * time.Second + } + downloadTimeout := cfg.DownloadTimeout + if downloadTimeout <= 0 { + downloadTimeout = timeout + } + protocolVersion := strings.TrimSpace(cfg.ProtocolVersion) + if protocolVersion == "" { + protocolVersion = "1.0" + } + if cfg.RetryCount < 0 { + cfg.RetryCount = 0 + } + return &Client{ + endpoint: normalizeEndpoint(cfg.Endpoint), + token: strings.TrimSpace(cfg.Token), + protocolVersion: protocolVersion, + httpClient: &http.Client{Timeout: timeout}, + downloadClient: &http.Client{Timeout: downloadTimeout}, + retryCount: cfg.RetryCount, + cacheTTL: cfg.CacheTTL, + cache: map[string]cacheEntry{}, + } +} + +func normalizeEndpoint(endpoint string) string { + endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/") + if strings.HasSuffix(endpoint, "/v1") { + return strings.TrimSuffix(endpoint, "/v1") + } + return endpoint +} + +func NewClientFromConfig(cfg config.MarketplaceConfig) *Client { + return NewClient(ClientConfig{ + Endpoint: cfg.RegistryEndpoint, + Token: cfg.RegistryToken, + ProtocolVersion: cfg.ProtocolVersion, + Timeout: time.Duration(cfg.RequestTimeoutSeconds) * time.Second, + DownloadTimeout: time.Duration(cfg.DownloadTimeoutSeconds) * time.Second, + RetryCount: cfg.RetryCount, + CacheTTL: time.Duration(cfg.CacheTTLSeconds) * time.Second, + }) +} + +func IsEnabled(cfg config.MarketplaceConfig) bool { + return !cfg.DisableRemote && strings.TrimSpace(cfg.RegistryEndpoint) != "" +} + +func (c *Client) List(ctx context.Context, filter marketplace.Filter) (marketplace.ListResult, error) { + values := url.Values{} + if filter.Kind != "" { + values.Set("kind", string(filter.Kind)) + } + if filter.Query != "" { + values.Set("q", filter.Query) + } + if filter.Risk != "" { + values.Set("risk", filter.Risk) + } + if filter.Trust != "" { + values.Set("trust", filter.Trust) + } + if filter.Tag != "" { + values.Set("tag", filter.Tag) + } + if filter.Permission != "" { + values.Set("permission", filter.Permission) + } + if filter.Publisher != "" { + values.Set("publisher", filter.Publisher) + } + if filter.OS != "" { + values.Set("os", filter.OS) + } + if filter.Arch != "" { + values.Set("arch", filter.Arch) + } + if filter.Sort != "" { + values.Set("sort", filter.Sort) + } + if filter.Limit > 0 { + values.Set("limit", fmt.Sprint(filter.Limit)) + } + if filter.Offset > 0 { + values.Set("offset", fmt.Sprint(filter.Offset)) + } + var envelope listEnvelope + if err := c.get(ctx, "/v1/artifacts?"+values.Encode(), &envelope); err != nil { + return marketplace.ListResult{}, err + } + items := make([]marketplace.Artifact, 0, len(envelope.Data.Items)) + for _, item := range envelope.Data.Items { + items = append(items, convertArtifact(item)) + } + return marketplace.ListResult{ + Items: items, + Total: envelope.Data.Total, + Limit: envelope.Data.Limit, + Offset: envelope.Data.Offset, + }, nil +} + +func (c *Client) Get(ctx context.Context, id string) (*marketplace.Artifact, error) { + var envelope artifactEnvelope + if err := c.get(ctx, "/v1/artifacts/"+url.PathEscape(strings.TrimSpace(id)), &envelope); err != nil { + return nil, err + } + item := convertArtifact(envelope.Data) + return &item, nil +} + +func (c *Client) Versions(ctx context.Context, id string) ([]marketplace.ArtifactVersion, error) { + var envelope versionsEnvelope + if err := c.get(ctx, "/v1/artifacts/"+url.PathEscape(strings.TrimSpace(id))+"/versions", &envelope); err != nil { + return nil, err + } + items := make([]marketplace.ArtifactVersion, 0, len(envelope.Data.Items)) + for _, item := range envelope.Data.Items { + items = append(items, convertVersion(item)) + } + return items, nil +} + +func (c *Client) Resolve(ctx context.Context, id string, req ResolveRequest) (ResolvedArtifact, error) { + var envelope resolveEnvelope + if err := c.post(ctx, "/v1/artifacts/"+url.PathEscape(strings.TrimSpace(id))+"/resolve", req, &envelope); err != nil { + return ResolvedArtifact{}, err + } + return envelope.Data, nil +} + +func (c *Client) Download(ctx context.Context, rawURL string) ([]byte, error) { + if c == nil { + return nil, ErrNotConfigured + } + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return nil, fmt.Errorf("download url is required") + } + var lastErr error + attempts := c.retryCount + 1 + for attempt := 0; attempt < attempts; attempt++ { + data, err := c.downloadOnce(ctx, rawURL) + if err == nil { + return data, nil + } + lastErr = err + if !retryable(err) || attempt == attempts-1 { + break + } + } + return nil, lastErr +} + +func (c *Client) get(ctx context.Context, path string, dst any) error { + if c == nil { + return ErrNotConfigured + } + if c.endpoint == "" { + return ErrNotConfigured + } + cacheKey := "GET " + path + if data, ok := c.cached(cacheKey); ok { + return json.Unmarshal(data, dst) + } + data, err := c.do(ctx, http.MethodGet, path, nil) + if err != nil { + return err + } + c.storeCache(cacheKey, data) + return json.Unmarshal(data, dst) +} + +func (c *Client) post(ctx context.Context, path string, body any, dst any) error { + if c == nil { + return ErrNotConfigured + } + if c.endpoint == "" { + return ErrNotConfigured + } + payload, err := json.Marshal(body) + if err != nil { + return err + } + data, err := c.do(ctx, http.MethodPost, path, payload) + if err != nil { + return err + } + return json.Unmarshal(data, dst) +} + +func (c *Client) do(ctx context.Context, method, path string, payload []byte) ([]byte, error) { + var lastErr error + attempts := c.retryCount + 1 + for attempt := 0; attempt < attempts; attempt++ { + data, err := c.doOnce(ctx, method, path, payload) + if err == nil { + return data, nil + } + lastErr = err + if !retryable(err) || attempt == attempts-1 { + break + } + } + return nil, lastErr +} + +func (c *Client) doOnce(ctx context.Context, method, path string, payload []byte) ([]byte, error) { + body := io.Reader(nil) + if payload != nil { + body = bytes.NewReader(payload) + } + req, err := http.NewRequestWithContext(ctx, method, c.endpoint+path, body) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("X-AnyClaw-Protocol-Version", c.protocolVersion) + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, remoteStatusError{StatusCode: resp.StatusCode, Body: string(data)} + } + return data, nil +} + +func (c *Client) shouldAuthorizeDownload(rawURL string) bool { + if c == nil { + return false + } + endpointURL, err := url.Parse(c.endpoint) + if err != nil { + return false + } + downloadURL, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return false + } + return strings.EqualFold(downloadURL.Scheme, endpointURL.Scheme) && strings.EqualFold(downloadURL.Host, endpointURL.Host) +} + +func (c *Client) downloadOnce(ctx context.Context, rawURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + if c.token != "" && c.shouldAuthorizeDownload(rawURL) { + req.Header.Set("Authorization", "Bearer "+c.token) + } + resp, err := c.downloadClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, 512<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, remoteStatusError{StatusCode: resp.StatusCode, Body: string(data)} + } + return data, nil +} + +func (c *Client) cached(key string) ([]byte, bool) { + if c.cacheTTL <= 0 { + return nil, false + } + c.mu.Lock() + defer c.mu.Unlock() + entry, ok := c.cache[key] + if !ok || time.Now().After(entry.expiresAt) { + delete(c.cache, key) + return nil, false + } + return append([]byte(nil), entry.data...), true +} + +func (c *Client) storeCache(key string, data []byte) { + if c.cacheTTL <= 0 { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.cache[key] = cacheEntry{ + expiresAt: time.Now().Add(c.cacheTTL), + data: append([]byte(nil), data...), + } +} + +func retryable(err error) bool { + var status remoteStatusError + if errors.As(err, &status) { + return status.StatusCode == http.StatusTooManyRequests || status.StatusCode >= 500 + } + return true +} + +type remoteStatusError struct { + StatusCode int + Body string +} + +func (e remoteStatusError) Error() string { + return fmt.Sprintf("marketplace registry returned HTTP %d", e.StatusCode) +} diff --git a/pkg/marketplace/registry/client_test.go b/pkg/marketplace/registry/client_test.go new file mode 100644 index 00000000..fd29a696 --- /dev/null +++ b/pkg/marketplace/registry/client_test.go @@ -0,0 +1,255 @@ +package registry + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/1024XEngineer/anyclaw/pkg/marketplace" +) + +func TestClientListConvertsCloudArtifactsAndCaches(t *testing.T) { + var listCalls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/artifacts" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + listCalls++ + if got := r.Header.Get("Authorization"); got != "Bearer test-token" { + t.Fatalf("Authorization = %q", got) + } + writeTestJSON(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.", + "description_md": "Detailed release notes skill.", + "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", "os": []string{"windows"}}, + "tags": []string{"release"}, + "hit_signals": []string{"changelog"}, + "score": 0.9, + }, + }, + "total": 1, + "limit": 10, + "offset": 0, + }, + }) + })) + defer server.Close() + + client := NewClient(ClientConfig{ + Endpoint: server.URL, + Token: "test-token", + ProtocolVersion: "1.0", + CacheTTL: time.Minute, + }) + result, err := client.List(context.Background(), marketplace.Filter{ + Kind: marketplace.ArtifactKindSkill, + Limit: 10, + }) + if err != nil { + t.Fatal(err) + } + if result.Total != 1 || len(result.Items) != 1 { + t.Fatalf("unexpected result: %#v", result) + } + item := result.Items[0] + if item.Source != marketplace.SourceCloud || item.Status != marketplace.StatusAvailable { + t.Fatalf("unexpected source/status: %#v", item) + } + if item.Owner != "AnyClaw Labs" || !item.Verified { + t.Fatalf("unexpected owner/trust: %#v", item) + } + if item.Description != "Detailed release notes skill." { + t.Fatalf("unexpected description %q", item.Description) + } + + if _, err := client.List(context.Background(), marketplace.Filter{Kind: marketplace.ArtifactKindSkill, Limit: 10}); err != nil { + t.Fatal(err) + } + if listCalls != 1 { + t.Fatalf("expected cached second list call, got %d server calls", listCalls) + } +} + +func TestClientDetailVersionsAndResolve(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/artifacts/cloud.agent.code-reviewer": + writeTestJSON(t, w, map[string]any{"data": testRemoteArtifact("agent")}) + case "/v1/artifacts/cloud.agent.code-reviewer/versions": + writeTestJSON(t, w, map[string]any{ + "data": map[string]any{ + "items": []map[string]any{{ + "version": "1.0.0", + "released_at": "2026-05-07T00:00:00Z", + "changelog_md": "Initial release.", + "permissions_diff": []string{"fs.read"}, + "size_bytes": 128, + }}, + "total": 1, + }, + }) + case "/v1/artifacts/cloud.agent.code-reviewer/resolve": + if r.Method != http.MethodPost { + t.Fatalf("resolve method = %s", r.Method) + } + writeTestJSON(t, w, map[string]any{ + "data": map[string]any{ + "artifact_id": "cloud.agent.code-reviewer", + "version": "1.0.0", + "download_url": serverURL(r) + "/v1/download/cloud.agent.code-reviewer/1.0.0", + "checksum_sha256": "abc", + "size_bytes": 128, + "compatibility": map[string]any{"anyclaw_min": "0.1.0"}, + "risk_level": "medium", + "trust_level": "verified", + "permissions": []string{"fs.read"}, + "kind": "agent", + "name": "Code Reviewer", + }, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + client := NewClient(ClientConfig{Endpoint: server.URL}) + artifact, err := client.Get(context.Background(), "cloud.agent.code-reviewer") + if err != nil { + t.Fatal(err) + } + if artifact.Kind != marketplace.ArtifactKindAgent || artifact.Source != marketplace.SourceCloud { + t.Fatalf("unexpected artifact: %#v", artifact) + } + + versions, err := client.Versions(context.Background(), "cloud.agent.code-reviewer") + if err != nil { + t.Fatal(err) + } + if len(versions) != 1 || versions[0].Version != "1.0.0" || versions[0].SizeBytes != 128 { + t.Fatalf("unexpected versions: %#v", versions) + } + + resolved, err := client.Resolve(context.Background(), "cloud.agent.code-reviewer", ResolveRequest{}) + if err != nil { + t.Fatal(err) + } + if resolved.ArtifactID != "cloud.agent.code-reviewer" || resolved.DownloadURL == "" { + t.Fatalf("unexpected resolve result: %#v", resolved) + } +} + +func TestClientAcceptsEndpointWithV1Path(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/artifacts" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + writeTestJSON(t, w, map[string]any{ + "data": map[string]any{ + "items": []map[string]any{testRemoteArtifact("skill")}, + "total": 1, + "limit": 1, + }, + }) + })) + defer server.Close() + + client := NewClient(ClientConfig{Endpoint: server.URL + "/v1"}) + result, err := client.List(context.Background(), marketplace.Filter{Limit: 1}) + if err != nil { + t.Fatal(err) + } + if result.Total != 1 || len(result.Items) != 1 { + t.Fatalf("unexpected result: %#v", result) + } +} + +func TestClientDownloadDoesNotSendTokenToDifferentOrigin(t *testing.T) { + registryServer := httptest.NewServer(http.NotFoundHandler()) + defer registryServer.Close() + + var downloadAuth string + downloadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + downloadAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("archive")) + })) + defer downloadServer.Close() + + client := NewClient(ClientConfig{Endpoint: registryServer.URL, Token: "secret-token"}) + data, err := client.Download(context.Background(), downloadServer.URL+"/artifact.zip") + if err != nil { + t.Fatal(err) + } + if string(data) != "archive" { + t.Fatalf("download data = %q", string(data)) + } + if downloadAuth != "" { + t.Fatalf("cross-origin download received Authorization header %q", downloadAuth) + } +} + +func TestClientDownloadUsesDownloadTimeout(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(75 * time.Millisecond) + _, _ = w.Write([]byte("late")) + })) + defer server.Close() + + client := NewClient(ClientConfig{ + Endpoint: server.URL, + Timeout: time.Second, + DownloadTimeout: time.Nanosecond, + }) + _, err := client.Download(context.Background(), server.URL+"/artifact.zip") + if err == nil { + t.Fatal("expected download timeout") + } + if !strings.Contains(err.Error(), "Client.Timeout") && !strings.Contains(err.Error(), "context deadline exceeded") { + t.Fatalf("expected timeout error, got %v", err) + } +} + +func testRemoteArtifact(kind string) map[string]any { + return map[string]any{ + "id": "cloud." + kind + ".code-reviewer", + "kind": kind, + "name": "Code Reviewer", + "summary": "Reviews code.", + "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"}, + } +} + +func writeTestJSON(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) + } +} + +func serverURL(r *http.Request) string { + return "http://" + r.Host +} diff --git a/pkg/marketplace/registry/convert.go b/pkg/marketplace/registry/convert.go new file mode 100644 index 00000000..6e8f8cfd --- /dev/null +++ b/pkg/marketplace/registry/convert.go @@ -0,0 +1,134 @@ +package registry + +import ( + "strconv" + "strings" + + "github.com/1024XEngineer/anyclaw/pkg/marketplace" +) + +func convertArtifact(item remoteArtifact) marketplace.Artifact { + metadata := map[string]string{} + for key, value := range item.ManifestSummary { + if strings.TrimSpace(key) != "" && strings.TrimSpace(value) != "" { + metadata[key] = value + } + } + if item.ChecksumSHA256 != "" { + metadata["checksum_sha256"] = item.ChecksumSHA256 + } + if item.IconURL != "" { + metadata["icon_url"] = item.IconURL + } + if item.UpdatedAt != "" { + metadata["updated_at"] = item.UpdatedAt + } + if item.SizeBytes > 0 { + metadata["size_bytes"] = strconv.FormatInt(item.SizeBytes, 10) + } + + description := firstNonEmpty(item.DescriptionMD, item.Summary) + version := firstNonEmpty(item.Version, item.LatestVersion) + return marketplace.Artifact{ + ID: item.ID, + Kind: item.Kind, + Name: item.Name, + DisplayName: item.Name, + Description: description, + Version: version, + LatestVersion: item.LatestVersion, + Source: marketplace.SourceCloud, + SourceID: firstNonEmpty(item.Source, "registry"), + Status: marketplace.StatusAvailable, + Installed: false, + Bound: false, + Active: false, + Enabled: true, + Owner: item.Publisher, + Category: string(item.Kind), + Tags: append([]string(nil), item.Tags...), + Permissions: append([]string(nil), item.Permissions...), + RiskLevel: item.RiskLevel, + TrustLevel: item.TrustLevel, + Verified: strings.EqualFold(item.TrustLevel, "verified"), + Compatibility: convertCompatibility(item.Compatibility), + Dependencies: convertDependencies(item.Dependencies), + HitSignals: append([]string(nil), item.HitSignals...), + Score: item.Score, + TargetHints: targetHintsForKind(item.Kind), + Capabilities: appendUnique(nil, append(append(item.Tags, item.HitSignals...), string(item.Kind))...), + Metadata: metadata, + } +} + +func convertVersion(item remoteVersion) marketplace.ArtifactVersion { + return marketplace.ArtifactVersion{ + Version: item.Version, + ReleasedAt: item.ReleasedAt, + ChangelogMD: item.ChangelogMD, + Compatibility: convertCompatibility(item.Compatibility), + PermissionsDiff: append([]string(nil), item.PermissionsDiff...), + SizeBytes: item.SizeBytes, + Deprecated: item.Deprecated, + } +} + +func convertCompatibility(item remoteCompatibility) marketplace.Compatibility { + return marketplace.Compatibility{ + AnyClawMin: item.AnyClawMin, + OS: append([]string(nil), item.OS...), + Arch: append([]string(nil), item.Arch...), + } +} + +func convertDependencies(items []remoteDependency) []marketplace.ArtifactDependency { + out := make([]marketplace.ArtifactDependency, 0, len(items)) + for _, item := range items { + out = append(out, marketplace.ArtifactDependency{ + ID: item.ID, + VersionRange: item.VersionRange, + }) + } + return out +} + +func targetHintsForKind(kind marketplace.ArtifactKind) []string { + switch kind { + case marketplace.ArtifactKindAgent: + return []string{"main_agent", "persistent_subagent", "workspace"} + case marketplace.ArtifactKindSkill: + return []string{"main_agent", "persistent_subagent", "workspace"} + case marketplace.ArtifactKindCLI: + return []string{"workspace", "runtime_global"} + default: + return nil + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func appendUnique(base []string, values ...string) []string { + out := append([]string(nil), base...) + seen := make(map[string]bool, len(out)+len(values)) + filtered := make([]string, 0, len(out)+len(values)) + for _, value := range append(out, values...) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + key := strings.ToLower(trimmed) + if seen[key] { + continue + } + seen[key] = true + filtered = append(filtered, trimmed) + } + return filtered +} diff --git a/pkg/marketplace/registry/types.go b/pkg/marketplace/registry/types.go new file mode 100644 index 00000000..e3e5de32 --- /dev/null +++ b/pkg/marketplace/registry/types.go @@ -0,0 +1,102 @@ +package registry + +import "github.com/1024XEngineer/anyclaw/pkg/marketplace" + +type remoteArtifact struct { + ID string `json:"id"` + Kind marketplace.ArtifactKind `json:"kind"` + Name string `json:"name"` + Summary string `json:"summary"` + DescriptionMD string `json:"description_md,omitempty"` + Version string `json:"version"` + LatestVersion string `json:"latest_version"` + Source string `json:"source"` + Publisher string `json:"publisher"` + RiskLevel string `json:"risk_level"` + TrustLevel string `json:"trust_level"` + Permissions []string `json:"permissions"` + Compatibility remoteCompatibility `json:"compatibility"` + Dependencies []remoteDependency `json:"dependencies,omitempty"` + SizeBytes int64 `json:"size_bytes,omitempty"` + ChecksumSHA256 string `json:"checksum_sha256,omitempty"` + IconURL string `json:"icon_url,omitempty"` + Tags []string `json:"tags,omitempty"` + HitSignals []string `json:"hit_signals,omitempty"` + Score float64 `json:"score,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + ManifestSummary map[string]string `json:"manifest_summary,omitempty"` +} + +type remoteCompatibility struct { + AnyClawMin string `json:"anyclaw_min,omitempty"` + OS []string `json:"os,omitempty"` + Arch []string `json:"arch,omitempty"` +} + +type remoteDependency struct { + ID string `json:"id"` + VersionRange string `json:"version_range,omitempty"` +} + +type remoteVersion struct { + ArtifactID string `json:"artifact_id,omitempty"` + Version string `json:"version"` + ReleasedAt string `json:"released_at,omitempty"` + ChangelogMD string `json:"changelog_md,omitempty"` + Compatibility remoteCompatibility `json:"compatibility,omitempty"` + Permissions []string `json:"permissions,omitempty"` + PermissionsDiff []string `json:"permissions_diff,omitempty"` + SizeBytes int64 `json:"size_bytes,omitempty"` + ChecksumSHA256 string `json:"checksum_sha256,omitempty"` + Deprecated bool `json:"deprecated,omitempty"` +} + +type ResolveRequest struct { + VersionConstraint string `json:"version_constraint,omitempty"` + ClientEnv struct { + AnyClawVersion string `json:"anyclaw_version,omitempty"` + OS string `json:"os,omitempty"` + Arch string `json:"arch,omitempty"` + } `json:"client_env,omitempty"` +} + +type ResolvedArtifact struct { + ArtifactID string `json:"artifact_id"` + Version string `json:"version"` + DownloadURL string `json:"download_url"` + ChecksumSHA256 string `json:"checksum_sha256"` + Signature string `json:"signature,omitempty"` + SizeBytes int64 `json:"size_bytes"` + ManifestURL string `json:"manifest_url,omitempty"` + Compatibility marketplace.Compatibility `json:"compatibility"` + Dependencies []marketplace.ArtifactDependency `json:"dependencies,omitempty"` + RiskLevel string `json:"risk_level"` + TrustLevel string `json:"trust_level"` + Permissions []string `json:"permissions"` + Kind marketplace.ArtifactKind `json:"kind"` + Name string `json:"name"` +} + +type listEnvelope struct { + Data struct { + Items []remoteArtifact `json:"items"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + } `json:"data"` +} + +type artifactEnvelope struct { + Data remoteArtifact `json:"data"` +} + +type versionsEnvelope struct { + Data struct { + Items []remoteVersion `json:"items"` + Total int `json:"total"` + } `json:"data"` +} + +type resolveEnvelope struct { + Data ResolvedArtifact `json:"data"` +} diff --git a/pkg/marketplace/store.go b/pkg/marketplace/store.go new file mode 100644 index 00000000..613eaf8f --- /dev/null +++ b/pkg/marketplace/store.go @@ -0,0 +1,749 @@ +package marketplace + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +type Store struct { + mu sync.Mutex + root string +} + +func NewStore(root string) *Store { + return &Store{root: root} +} + +func (s *Store) Root() string { + if s == nil { + return "" + } + return s.root +} + +func (s *Store) MarketplaceDir() string { + return filepath.Join(s.root, ".anyclaw", "marketplace") +} + +func (s *Store) InstalledDir() string { + return filepath.Join(s.MarketplaceDir(), "installed") +} + +func (s *Store) ReceiptsDir() string { + return filepath.Join(s.MarketplaceDir(), "receipts") +} + +func (s *Store) JobsDir() string { + return filepath.Join(s.MarketplaceDir(), "jobs") +} + +func (s *Store) BindingsDir() string { + return filepath.Join(s.MarketplaceDir(), "bindings") +} + +func (s *Store) AuditDir() string { + return filepath.Join(s.MarketplaceDir(), "audit") +} + +func (s *Store) EventsDir() string { + return filepath.Join(s.MarketplaceDir(), "events") +} + +func (s *Store) Ensure() error { + for _, dir := range []string{s.InstalledDir(), s.ReceiptsDir(), s.JobsDir(), s.BindingsDir(), s.AuditDir(), s.EventsDir()} { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + } + return nil +} + +func (s *Store) CreateInstallJob(req InstallRequest, idempotencyKey string) (*InstallJob, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.Ensure(); err != nil { + return nil, false, err + } + idempotencyKey = strings.TrimSpace(idempotencyKey) + if idempotencyKey != "" { + index, err := s.loadIdempotencyIndex() + if err != nil { + return nil, false, err + } + if jobID := index[idempotencyKey]; jobID != "" { + job, err := s.getJobLocked(jobID) + if err == nil { + return job, true, nil + } + } + } + now := time.Now().UTC().Format(time.RFC3339) + job := &InstallJob{ + ID: "market-job-" + time.Now().UTC().Format("20060102150405.000000000"), + Type: "install", + State: JobPending, + ArtifactID: strings.TrimSpace(req.ArtifactID), + VersionConstraint: strings.TrimSpace(req.VersionConstraint), + ProgressTotal: 5, + IdempotencyKey: idempotencyKey, + InstalledBy: firstNonEmpty(strings.TrimSpace(req.InstalledBy), "user"), + Metadata: map[string]string{ + "user_confirmed": marketBoolString(req.UserConfirmed), + "risk_acknowledged": marketBoolString(req.RiskAcknowledged), + }, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.saveJobLocked(job); err != nil { + return nil, false, err + } + if idempotencyKey != "" { + index, err := s.loadIdempotencyIndex() + if err != nil { + return nil, false, err + } + index[idempotencyKey] = job.ID + if err := s.saveIdempotencyIndex(index); err != nil { + return nil, false, err + } + } + return job, false, nil +} + +func (s *Store) CreateUpgradeJob(req UpgradeRequest, idempotencyKey string) (*InstallJob, bool, error) { + installReq := InstallRequest{ + ArtifactID: req.ArtifactID, + VersionConstraint: req.VersionConstraint, + InstalledBy: req.InstalledBy, + UserConfirmed: req.UserConfirmed, + RiskAcknowledged: req.RiskAcknowledged, + IdempotencyKey: req.IdempotencyKey, + } + job, reused, err := s.CreateInstallJob(installReq, idempotencyKey) + if err != nil || reused { + return job, reused, err + } + job.Type = "upgrade" + if job.Metadata == nil { + job.Metadata = map[string]string{} + } + job.Metadata["previous_version"] = "" + if previous, err := s.LatestReceiptForArtifact(req.ArtifactID); err == nil { + job.Metadata["previous_version"] = previous.Version + job.Metadata["previous_receipt_id"] = previous.ID + } + if err := s.UpdateJob(job); err != nil { + return nil, false, err + } + return job, false, nil +} + +func (s *Store) GetJob(id string) (*InstallJob, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.getJobLocked(id) +} + +func (s *Store) ListJobs(limit int) (JobListResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.Ensure(); err != nil { + return JobListResult{}, err + } + entries, err := os.ReadDir(s.JobsDir()) + if err != nil { + return JobListResult{}, err + } + var items []InstallJob + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + job, err := s.getJobLocked(strings.TrimSuffix(entry.Name(), ".json")) + if err == nil { + items = append(items, *job) + } + } + sort.SliceStable(items, func(i, j int) bool { return items[i].CreatedAt > items[j].CreatedAt }) + if limit <= 0 { + limit = 100 + } + total := len(items) + if len(items) > limit { + items = items[:limit] + } + return JobListResult{Items: items, Total: total}, nil +} + +func (s *Store) UpdateJob(job *InstallJob) error { + s.mu.Lock() + defer s.mu.Unlock() + job.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + return s.saveJobLocked(job) +} + +func (s *Store) SaveReceipt(receipt *InstallReceipt) error { + s.mu.Lock() + defer s.mu.Unlock() + if receipt == nil { + return fmt.Errorf("receipt is nil") + } + if err := os.MkdirAll(s.ReceiptsDir(), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(receipt, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(s.receiptPath(receipt.ID), data, 0o644) +} + +func (s *Store) AppendAudit(event MarketAuditEvent) error { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.Ensure(); err != nil { + return err + } + if event.ID == "" { + event.ID = "market-audit-" + time.Now().UTC().Format("20060102150405.000000000") + } + if event.CreatedAt == "" { + event.CreatedAt = time.Now().UTC().Format(time.RFC3339) + } + data, err := json.Marshal(event) + if err != nil { + return err + } + f, err := os.OpenFile(s.auditPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + if _, err := f.Write(append(data, '\n')); err != nil { + return err + } + return nil +} + +func (s *Store) AppendEvent(event MarketEvent) error { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.Ensure(); err != nil { + return err + } + if event.ID == "" { + event.ID = "market-event-" + time.Now().UTC().Format("20060102150405.000000000") + } + if event.CreatedAt == "" { + event.CreatedAt = time.Now().UTC().Format(time.RFC3339) + } + data, err := json.MarshalIndent(event, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(s.eventPath(event.ID), data, 0o644) +} + +func (s *Store) ListEvents(limit int) (MarketEventListResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.Ensure(); err != nil { + return MarketEventListResult{}, err + } + entries, err := os.ReadDir(s.EventsDir()) + if err != nil { + return MarketEventListResult{}, err + } + var items []MarketEvent + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + data, err := os.ReadFile(filepath.Join(s.EventsDir(), entry.Name())) + if err != nil { + continue + } + var event MarketEvent + if err := json.Unmarshal(data, &event); err == nil && event.ID != "" { + items = append(items, event) + } + } + sort.SliceStable(items, func(i, j int) bool { return items[i].CreatedAt > items[j].CreatedAt }) + if limit <= 0 { + limit = 100 + } + total := len(items) + if len(items) > limit { + items = items[:limit] + } + return MarketEventListResult{Items: items, Total: total}, nil +} + +func (s *Store) ListReceipts() ([]InstallReceipt, error) { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.Ensure(); err != nil { + return nil, err + } + entries, err := os.ReadDir(s.ReceiptsDir()) + if err != nil { + return nil, err + } + var receipts []InstallReceipt + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + data, err := os.ReadFile(filepath.Join(s.ReceiptsDir(), entry.Name())) + if err != nil { + continue + } + var receipt InstallReceipt + if err := json.Unmarshal(data, &receipt); err == nil && receipt.ID != "" { + receipts = append(receipts, receipt) + } + } + return receipts, nil +} + +func (s *Store) LatestReceiptForArtifact(artifactID string) (*InstallReceipt, error) { + receipts, err := s.ListReceipts() + if err != nil { + return nil, err + } + var best *InstallReceipt + for i := range receipts { + if !strings.EqualFold(strings.TrimSpace(receipts[i].ArtifactID), strings.TrimSpace(artifactID)) { + continue + } + if best == nil || receipts[i].InstalledAt > best.InstalledAt { + copy := receipts[i] + best = © + } + } + if best == nil { + return nil, ErrArtifactNotFound + } + return best, nil +} + +func (s *Store) GetReceipt(id string) (*InstallReceipt, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.getReceiptLocked(id) +} + +func (s *Store) CreateBinding(req BindingRequest) (*Binding, error) { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.Ensure(); err != nil { + return nil, err + } + receiptID := strings.TrimSpace(req.ReceiptID) + var receipt *InstallReceipt + var err error + if receiptID != "" { + receipt, err = s.getReceiptLocked(receiptID) + } else { + receipt, err = s.latestReceiptForArtifactLocked(req.ArtifactID) + } + if err != nil { + return nil, err + } + targetType := NormalizeBindingTargetType(string(req.TargetType)) + if targetType == "" { + return nil, fmt.Errorf("invalid target_type") + } + now := time.Now().UTC().Format(time.RFC3339) + binding := &Binding{ + ID: "binding-" + time.Now().UTC().Format("20060102150405.000000000"), + ArtifactID: receipt.ArtifactID, + ReceiptID: receipt.ID, + Kind: receipt.Kind, + Version: receipt.Version, + TargetType: targetType, + TargetID: strings.TrimSpace(req.TargetID), + State: BindingEnabled, + CreatedAt: now, + UpdatedAt: now, + Metadata: cloneStringMap(req.Metadata), + } + if binding.TargetID == "" && targetType != TargetRuntimeGlobal { + return nil, fmt.Errorf("target_id is required for %s", targetType) + } + if err := s.saveBindingLocked(binding); err != nil { + return nil, err + } + return binding, nil +} + +func (s *Store) ListBindings() (BindingListResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.Ensure(); err != nil { + return BindingListResult{}, err + } + entries, err := os.ReadDir(s.BindingsDir()) + if err != nil { + return BindingListResult{}, err + } + var items []Binding + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + binding, err := s.getBindingLocked(strings.TrimSuffix(entry.Name(), ".json")) + if err == nil { + items = append(items, *binding) + } + } + sort.SliceStable(items, func(i, j int) bool { return items[i].CreatedAt > items[j].CreatedAt }) + return BindingListResult{Items: items, Total: len(items)}, nil +} + +func (s *Store) DeleteBinding(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + path := s.bindingPath(id) + if err := os.Remove(path); err != nil { + if os.IsNotExist(err) { + return ErrArtifactNotFound + } + return err + } + return nil +} + +func (s *Store) DeleteBindingsForArtifact(artifactID string) ([]Binding, error) { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.Ensure(); err != nil { + return nil, err + } + entries, err := os.ReadDir(s.BindingsDir()) + if err != nil { + return nil, err + } + var removed []Binding + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + id := strings.TrimSuffix(entry.Name(), ".json") + binding, err := s.getBindingLocked(id) + if err != nil { + continue + } + if !strings.EqualFold(strings.TrimSpace(binding.ArtifactID), strings.TrimSpace(artifactID)) { + continue + } + if err := os.Remove(s.bindingPath(binding.ID)); err != nil && !os.IsNotExist(err) { + return removed, err + } + removed = append(removed, *binding) + } + return removed, nil +} + +func (s *Store) UpdateBindingsForArtifactReceipt(artifactID, receiptID, version string) ([]Binding, error) { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.Ensure(); err != nil { + return nil, err + } + entries, err := os.ReadDir(s.BindingsDir()) + if err != nil { + return nil, err + } + var updated []Binding + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + id := strings.TrimSuffix(entry.Name(), ".json") + binding, err := s.getBindingLocked(id) + if err != nil { + continue + } + if !strings.EqualFold(strings.TrimSpace(binding.ArtifactID), strings.TrimSpace(artifactID)) { + continue + } + binding.ReceiptID = receiptID + binding.Version = version + binding.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + if err := s.saveBindingLocked(binding); err != nil { + return updated, err + } + updated = append(updated, *binding) + } + return updated, nil +} + +func (s *Store) DeleteReceipt(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + path := s.receiptPath(id) + if err := os.Remove(path); err != nil { + if os.IsNotExist(err) { + return ErrArtifactNotFound + } + return err + } + return nil +} + +func (s *Store) OverlayStatus(items []Artifact) []Artifact { + receipts, _ := s.ListReceipts() + bindingsResult, _ := s.ListBindings() + installed := map[string]InstallReceipt{} + for _, receipt := range receipts { + key := strings.ToLower(strings.TrimSpace(receipt.ArtifactID)) + if key == "" { + continue + } + if current, ok := installed[key]; !ok || receipt.InstalledAt > current.InstalledAt { + installed[key] = receipt + } + } + bound := map[string]Binding{} + active := map[string]Binding{} + for _, binding := range bindingsResult.Items { + if binding.State != BindingEnabled { + continue + } + key := strings.ToLower(strings.TrimSpace(binding.ArtifactID)) + bound[key] = binding + if binding.TargetType == TargetMainAgent || binding.TargetType == TargetRuntimeGlobal { + active[key] = binding + } + } + out := append([]Artifact(nil), items...) + for i := range out { + key := strings.ToLower(strings.TrimSpace(out[i].ID)) + if receipt, ok := installed[key]; ok { + out[i].Installed = true + out[i].Enabled = true + out[i].Status = StatusInstalled + out[i].Version = firstNonEmpty(out[i].Version, receipt.Version) + } + if _, ok := bound[key]; ok { + out[i].Bound = true + out[i].Status = StatusBound + } + if _, ok := active[key]; ok { + out[i].Active = true + out[i].Status = StatusActive + } + } + return out +} + +func (s *Store) ReceiptPath(id string) string { + return s.receiptPath(id) +} + +func (s *Store) AuditPath() string { + return s.auditPath() +} + +func (s *Store) receiptPath(id string) string { + return filepath.Join(s.ReceiptsDir(), safeName(id)+".json") +} + +func (s *Store) auditPath() string { + return filepath.Join(s.AuditDir(), "marketplace.jsonl") +} + +func (s *Store) eventPath(id string) string { + return filepath.Join(s.EventsDir(), safeName(id)+".json") +} + +func (s *Store) getReceiptLocked(id string) (*InstallReceipt, error) { + data, err := os.ReadFile(s.receiptPath(id)) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrArtifactNotFound + } + return nil, err + } + var receipt InstallReceipt + if err := json.Unmarshal(data, &receipt); err != nil { + return nil, err + } + return &receipt, nil +} + +func (s *Store) latestReceiptForArtifactLocked(artifactID string) (*InstallReceipt, error) { + entries, err := os.ReadDir(s.ReceiptsDir()) + if err != nil { + return nil, err + } + var best *InstallReceipt + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + data, err := os.ReadFile(filepath.Join(s.ReceiptsDir(), entry.Name())) + if err != nil { + continue + } + var receipt InstallReceipt + if err := json.Unmarshal(data, &receipt); err != nil { + continue + } + if strings.EqualFold(strings.TrimSpace(receipt.ArtifactID), strings.TrimSpace(artifactID)) { + if best == nil || receipt.InstalledAt > best.InstalledAt { + copy := receipt + best = © + } + } + } + if best == nil { + return nil, ErrArtifactNotFound + } + return best, nil +} + +func (s *Store) getBindingLocked(id string) (*Binding, error) { + data, err := os.ReadFile(s.bindingPath(id)) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrArtifactNotFound + } + return nil, err + } + var binding Binding + if err := json.Unmarshal(data, &binding); err != nil { + return nil, err + } + return &binding, nil +} + +func (s *Store) saveBindingLocked(binding *Binding) error { + if binding == nil { + return fmt.Errorf("binding is nil") + } + if err := os.MkdirAll(s.BindingsDir(), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(binding, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(s.bindingPath(binding.ID), data, 0o644) +} + +func (s *Store) bindingPath(id string) string { + return filepath.Join(s.BindingsDir(), safeName(id)+".json") +} + +func (s *Store) getJobLocked(id string) (*InstallJob, error) { + data, err := os.ReadFile(s.jobPath(id)) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrArtifactNotFound + } + return nil, err + } + var job InstallJob + if err := json.Unmarshal(data, &job); err != nil { + return nil, err + } + return &job, nil +} + +func (s *Store) saveJobLocked(job *InstallJob) error { + if job == nil { + return fmt.Errorf("job is nil") + } + if err := os.MkdirAll(s.JobsDir(), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(job, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(s.jobPath(job.ID), data, 0o644) +} + +func (s *Store) jobPath(id string) string { + return filepath.Join(s.JobsDir(), safeName(id)+".json") +} + +func (s *Store) loadIdempotencyIndex() (map[string]string, error) { + path := filepath.Join(s.MarketplaceDir(), "idempotency.json") + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return map[string]string{}, nil + } + return nil, err + } + var index map[string]string + if err := json.Unmarshal(data, &index); err != nil { + return nil, err + } + if index == nil { + index = map[string]string{} + } + return index, nil +} + +func (s *Store) saveIdempotencyIndex(index map[string]string) error { + if err := os.MkdirAll(s.MarketplaceDir(), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(index, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(filepath.Join(s.MarketplaceDir(), "idempotency.json"), data, 0o644) +} + +func safeName(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + replacer := strings.NewReplacer("/", "-", "\\", "-", ":", "-", " ", "-", ".", "-") + value = replacer.Replace(value) + value = strings.Trim(value, "-") + if value == "" { + return "item" + } + return value +} + +func cloneStringMap(items map[string]string) map[string]string { + if len(items) == 0 { + return nil + } + out := make(map[string]string, len(items)) + for key, value := range items { + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if key != "" && value != "" { + out[key] = value + } + } + if len(out) == 0 { + return nil + } + return out +} + +func marketBoolString(value bool) string { + if value { + return "true" + } + return "false" +} diff --git a/pkg/marketplace/types.go b/pkg/marketplace/types.go new file mode 100644 index 00000000..3cd72e36 --- /dev/null +++ b/pkg/marketplace/types.go @@ -0,0 +1,412 @@ +package marketplace + +import "strings" + +type ArtifactKind string + +const ( + ArtifactKindAgent ArtifactKind = "agent" + ArtifactKindSkill ArtifactKind = "skill" + ArtifactKindCLI ArtifactKind = "cli" + ArtifactKindPlugin ArtifactKind = "plugin" +) + +type SourceKind string + +const ( + SourceLocal SourceKind = "local" + SourceCloud SourceKind = "cloud" +) + +type ArtifactStatus string + +const ( + StatusAvailable ArtifactStatus = "available" + StatusInstalling ArtifactStatus = "installing" + StatusInstalled ArtifactStatus = "installed" + StatusBound ArtifactStatus = "bound" + StatusActive ArtifactStatus = "active" + StatusDisabled ArtifactStatus = "disabled" + StatusError ArtifactStatus = "error" + StatusRolledBack ArtifactStatus = "rolled_back" + StatusQuarantined ArtifactStatus = "quarantined" +) + +type DecisionMode string + +const ( + DecisionAuto DecisionMode = "auto" + DecisionAsk DecisionMode = "ask" + DecisionBlock DecisionMode = "block" +) + +type Compatibility struct { + AnyClawMin string `json:"anyclaw_min,omitempty"` + OS []string `json:"os,omitempty"` + Arch []string `json:"arch,omitempty"` +} + +type ArtifactDependency struct { + ID string `json:"id"` + VersionRange string `json:"version_range,omitempty"` +} + +type ArtifactVersion struct { + Version string `json:"version"` + ReleasedAt string `json:"released_at,omitempty"` + ChangelogMD string `json:"changelog_md,omitempty"` + Compatibility Compatibility `json:"compatibility,omitempty"` + PermissionsDiff []string `json:"permissions_diff,omitempty"` + SizeBytes int64 `json:"size_bytes,omitempty"` + Deprecated bool `json:"deprecated,omitempty"` +} + +type Artifact struct { + ID string `json:"id"` + Kind ArtifactKind `json:"kind"` + Name string `json:"name"` + DisplayName string `json:"display_name,omitempty"` + Description string `json:"description,omitempty"` + Version string `json:"version,omitempty"` + LatestVersion string `json:"latest_version,omitempty"` + Source SourceKind `json:"source"` + SourceID string `json:"source_id,omitempty"` + Status ArtifactStatus `json:"status"` + Installed bool `json:"installed"` + Bound bool `json:"bound"` + Active bool `json:"active"` + Enabled bool `json:"enabled"` + Owner string `json:"owner,omitempty"` + Category string `json:"category,omitempty"` + Tags []string `json:"tags,omitempty"` + Permissions []string `json:"permissions,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + TrustLevel string `json:"trust_level,omitempty"` + Verified bool `json:"verified,omitempty"` + Compatibility Compatibility `json:"compatibility,omitempty"` + Dependencies []ArtifactDependency `json:"dependencies,omitempty"` + HitSignals []string `json:"hit_signals,omitempty"` + Score float64 `json:"score,omitempty"` + InstallHint string `json:"install_hint,omitempty"` + TargetHints []string `json:"target_hints,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type JobState string + +const ( + JobPending JobState = "pending" + JobRunning JobState = "running" + JobRollingBack JobState = "rolling_back" + JobSucceeded JobState = "succeeded" + JobFailed JobState = "failed" + JobCanceled JobState = "canceled" + JobRolledBack JobState = "rolled_back" + JobInterrupted JobState = "interrupted" +) + +type InstallRequest struct { + ArtifactID string `json:"artifact_id"` + VersionConstraint string `json:"version_constraint,omitempty"` + InstalledBy string `json:"installed_by,omitempty"` + UserConfirmed bool `json:"user_confirmed,omitempty"` + RiskAcknowledged bool `json:"risk_acknowledged,omitempty"` + IdempotencyKey string `json:"-"` +} + +type UpgradeRequest struct { + ArtifactID string `json:"artifact_id"` + VersionConstraint string `json:"version_constraint,omitempty"` + InstalledBy string `json:"installed_by,omitempty"` + UserConfirmed bool `json:"user_confirmed,omitempty"` + RiskAcknowledged bool `json:"risk_acknowledged,omitempty"` + IdempotencyKey string `json:"-"` +} + +type UninstallRequest struct { + ArtifactID string `json:"artifact_id"` + ReceiptID string `json:"receipt_id,omitempty"` + Actor string `json:"actor,omitempty"` +} + +type UninstallResult struct { + ArtifactID string `json:"artifact_id"` + ReceiptID string `json:"receipt_id"` + RemovedBindings []string `json:"removed_bindings"` + RemovedPath string `json:"removed_path,omitempty"` + UninstalledAt string `json:"uninstalled_at"` + PreviousVersion string `json:"previous_version,omitempty"` + UndoAvailableSec int `json:"undo_available_seconds,omitempty"` +} + +type PolicyDecision struct { + Decision DecisionMode `json:"decision"` + Reason string `json:"reason,omitempty"` + Reasons []string `json:"reasons,omitempty"` + RequiresUserConfirmation bool `json:"requires_user_confirmation,omitempty"` + RequiresRiskAcknowledgement bool `json:"requires_risk_acknowledgement,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + TrustLevel string `json:"trust_level,omitempty"` + Permissions []string `json:"permissions,omitempty"` + HighRiskPermissions []string `json:"high_risk_permissions,omitempty"` +} + +type InstallJob struct { + ID string `json:"id"` + Type string `json:"type"` + State JobState `json:"state"` + ArtifactID string `json:"artifact_id"` + Version string `json:"version,omitempty"` + VersionConstraint string `json:"version_constraint,omitempty"` + ProgressStep string `json:"progress_step,omitempty"` + ProgressIndex int `json:"progress_index"` + ProgressTotal int `json:"progress_total"` + Error string `json:"error,omitempty"` + ReceiptID string `json:"receipt_id,omitempty"` + InstalledPath string `json:"installed_path,omitempty"` + ChecksumSHA256 string `json:"checksum_sha256,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` + InstalledBy string `json:"installed_by,omitempty"` + Decision *PolicyDecision `json:"decision,omitempty"` + RolledBack bool `json:"rolled_back,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + CompletedAt string `json:"completed_at,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type JobListResult struct { + Items []InstallJob `json:"items"` + Total int `json:"total"` +} + +type InstallReceipt struct { + ID string `json:"id"` + JobID string `json:"job_id"` + ArtifactID string `json:"artifact_id"` + Kind ArtifactKind `json:"kind"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Version string `json:"version"` + Source SourceKind `json:"source"` + SourceID string `json:"source_id,omitempty"` + InstalledPath string `json:"installed_path"` + InstalledBy string `json:"installed_by"` + InstalledAt string `json:"installed_at"` + ChecksumSHA256 string `json:"checksum_sha256"` + Permissions []string `json:"permissions,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + TrustLevel string `json:"trust_level,omitempty"` + Compatibility Compatibility `json:"compatibility,omitempty"` + Dependencies []ArtifactDependency `json:"dependencies,omitempty"` + Decision *PolicyDecision `json:"decision,omitempty"` +} + +type BindingTargetType string + +const ( + TargetMainAgent BindingTargetType = "main_agent" + TargetPersistentSubagent BindingTargetType = "persistent_subagent" + TargetWorkspace BindingTargetType = "workspace" + TargetRuntimeGlobal BindingTargetType = "runtime_global" +) + +type BindingState string + +const ( + BindingEnabled BindingState = "enabled" + BindingDisabled BindingState = "disabled" +) + +type BindingRequest struct { + ArtifactID string `json:"artifact_id"` + ReceiptID string `json:"receipt_id,omitempty"` + TargetType BindingTargetType `json:"target_type"` + TargetID string `json:"target_id,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type Binding struct { + ID string `json:"id"` + ArtifactID string `json:"artifact_id"` + ReceiptID string `json:"receipt_id"` + Kind ArtifactKind `json:"kind"` + Version string `json:"version"` + TargetType BindingTargetType `json:"target_type"` + TargetID string `json:"target_id"` + TargetName string `json:"target_name,omitempty"` + State BindingState `json:"state"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type BindingListResult struct { + Items []Binding `json:"items"` + Total int `json:"total"` +} + +type MarketAuditEvent struct { + ID string `json:"id"` + Type string `json:"type"` + ArtifactID string `json:"artifact_id,omitempty"` + JobID string `json:"job_id,omitempty"` + BindingID string `json:"binding_id,omitempty"` + Actor string `json:"actor,omitempty"` + Decision string `json:"decision,omitempty"` + Reason string `json:"reason,omitempty"` + Detail map[string]any `json:"detail,omitempty"` + CreatedAt string `json:"created_at"` +} + +type MarketEvent struct { + ID string `json:"id"` + Type string `json:"type"` + Level string `json:"level,omitempty"` + Message string `json:"message,omitempty"` + ArtifactID string `json:"artifact_id,omitempty"` + JobID string `json:"job_id,omitempty"` + BindingID string `json:"binding_id,omitempty"` + Payload map[string]any `json:"payload,omitempty"` + CreatedAt string `json:"created_at"` +} + +type MarketEventListResult struct { + Items []MarketEvent `json:"items"` + Total int `json:"total"` +} + +type CapabilityIndexItem struct { + ArtifactID string `json:"artifact_id"` + Kind ArtifactKind `json:"kind"` + Name string `json:"name"` + Source SourceKind `json:"source"` + Status string `json:"status"` + Capabilities []string `json:"capabilities,omitempty"` + Permissions []string `json:"permissions,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + TrustLevel string `json:"trust_level,omitempty"` + Score float64 `json:"score,omitempty"` +} + +type CapabilityRoute struct { + Need string `json:"need"` + InstalledMatch *CapabilityIndexItem `json:"installed_match,omitempty"` + CloudMatches []CapabilityIndexItem `json:"cloud_matches,omitempty"` + Action string `json:"action"` + Reason string `json:"reason"` +} + +type Filter struct { + Kind ArtifactKind + Source SourceKind + Query string + Status ArtifactStatus + Risk string + Trust string + Tag string + Permission string + Publisher string + OS string + Arch string + Sort string + Limit int + Offset int +} + +type ListResult struct { + Items []Artifact `json:"items"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +func NormalizeKind(value string) ArtifactKind { + switch ArtifactKind(strings.ToLower(strings.TrimSpace(value))) { + case ArtifactKindAgent: + return ArtifactKindAgent + case ArtifactKindSkill: + return ArtifactKindSkill + case ArtifactKindCLI: + return ArtifactKindCLI + case ArtifactKindPlugin: + return ArtifactKindPlugin + default: + return "" + } +} + +func NormalizeSource(value string) SourceKind { + switch SourceKind(strings.ToLower(strings.TrimSpace(value))) { + case SourceLocal: + return SourceLocal + case SourceCloud: + return SourceCloud + default: + return "" + } +} + +func NormalizeStatus(value string) ArtifactStatus { + switch ArtifactStatus(strings.ToLower(strings.TrimSpace(value))) { + case StatusAvailable: + return StatusAvailable + case StatusInstalling: + return StatusInstalling + case StatusInstalled: + return StatusInstalled + case StatusBound: + return StatusBound + case StatusActive: + return StatusActive + case StatusDisabled: + return StatusDisabled + case StatusError: + return StatusError + case StatusRolledBack: + return StatusRolledBack + case StatusQuarantined: + return StatusQuarantined + default: + return "" + } +} + +func NormalizeJobState(value string) JobState { + switch JobState(strings.ToLower(strings.TrimSpace(value))) { + case JobPending: + return JobPending + case JobRunning: + return JobRunning + case JobRollingBack: + return JobRollingBack + case JobSucceeded: + return JobSucceeded + case JobFailed: + return JobFailed + case JobCanceled: + return JobCanceled + case JobRolledBack: + return JobRolledBack + case JobInterrupted: + return JobInterrupted + default: + return "" + } +} + +func NormalizeBindingTargetType(value string) BindingTargetType { + switch BindingTargetType(strings.ToLower(strings.TrimSpace(value))) { + case TargetMainAgent: + return TargetMainAgent + case TargetPersistentSubagent: + return TargetPersistentSubagent + case TargetWorkspace: + return TargetWorkspace + case TargetRuntimeGlobal: + return TargetRuntimeGlobal + default: + return "" + } +}