diff --git a/pkg/capability/agents/agent.go b/pkg/capability/agents/agent.go index 87b2791e..a0f99836 100644 --- a/pkg/capability/agents/agent.go +++ b/pkg/capability/agents/agent.go @@ -19,6 +19,7 @@ import ( "github.com/1024XEngineer/anyclaw/pkg/capability/tools" "github.com/1024XEngineer/anyclaw/pkg/clawbridge" "github.com/1024XEngineer/anyclaw/pkg/clihub" + "github.com/1024XEngineer/anyclaw/pkg/marketplace" ctxpkg "github.com/1024XEngineer/anyclaw/pkg/runtime/context/store" "github.com/1024XEngineer/anyclaw/pkg/state/memory" "github.com/1024XEngineer/anyclaw/pkg/workspace" @@ -133,10 +134,11 @@ func (a *Agent) Run(ctx context.Context, userInput string) (string, error) { if execResult, handled, err := a.tryAutoRouteCLIHubIntent(ctx, userInput); handled { return execResult, err } - selectedTools := a.selectToolInfos(userInput) + capabilityPlan := a.planCapability(userInput) + selectedTools := a.selectToolInfosWithPlan(userInput, capabilityPlan) a.appendHistoryMessage(ctx, "user", userInput) toolDefs := a.buildSelectedToolDefinitions(selectedTools) - systemPrompt, err := a.prepareSystemPrompt(ctx, selectedTools, toolDefs) + systemPrompt, err := a.prepareSystemPrompt(ctx, selectedTools, toolDefs, capabilityPlan) if err != nil { return "", err } @@ -183,10 +185,11 @@ func (a *Agent) RunStream(ctx context.Context, userInput string, onChunk func(st return nil } - selectedTools := a.selectToolInfos(userInput) + capabilityPlan := a.planCapability(userInput) + selectedTools := a.selectToolInfosWithPlan(userInput, capabilityPlan) a.appendHistoryMessage(ctx, "user", userInput) toolDefs := a.buildSelectedToolDefinitions(selectedTools) - systemPrompt, err := a.prepareSystemPrompt(ctx, selectedTools, toolDefs) + systemPrompt, err := a.prepareSystemPrompt(ctx, selectedTools, toolDefs, capabilityPlan) if err != nil { return err } @@ -424,6 +427,10 @@ func (a *Agent) BuildSystemPrompt() (string, error) { } func (a *Agent) buildSystemPromptForToolInfos(toolList []tools.ToolInfo) (string, error) { + return a.buildSystemPromptForToolInfosWithPlan(toolList, nil) +} + +func (a *Agent) buildSystemPromptForToolInfosWithPlan(toolList []tools.ToolInfo, capabilityPlan *CapabilityPlan) (string, error) { workspaceFiles := []prompt.WorkspaceFile{} if strings.TrimSpace(a.workingDir) != "" { files, err := a.loadBootstrapFiles() @@ -515,6 +522,25 @@ func (a *Agent) buildSystemPromptForToolInfos(toolList []tools.ToolInfo) (string WorkspaceFiles: workspaceFiles, History: a.history, } + if capabilityPlan != nil { + data.CapabilityPlan = prompt.CapabilityPlanInfo{ + TaskClass: capabilityPlan.TaskClass, + Route: capabilityPlan.Route, + Need: capabilityPlan.Need, + KindHint: capabilityPlan.KindHint, + ShouldExposeMarketSearch: capabilityPlan.ShouldExposeMarketSearch, + Reason: capabilityPlan.Reason, + } + if len(capabilityPlan.LocalMatches) > 0 { + match := capabilityPlan.LocalMatches[0] + data.CapabilityPlan.TopLocalMatch = prompt.CapabilityMatchInfo{ + Kind: match.Kind, + Name: match.Name, + Score: match.Score, + Reason: match.Reason, + } + } + } return prompt.BuildSystemPrompt(a.config.Name, description, data) } @@ -580,6 +606,10 @@ func readableSkillLocation(skill *skills.Skill) string { } func (a *Agent) selectToolInfos(userInput string) []tools.ToolInfo { + return a.selectToolInfosWithPlan(userInput, nil) +} + +func (a *Agent) selectToolInfosWithPlan(userInput string, capabilityPlan *CapabilityPlan) []tools.ToolInfo { if a.tools == nil { return nil } @@ -590,10 +620,12 @@ func (a *Agent) selectToolInfos(userInput string) []tools.ToolInfo { } query := normalizeToolSelectionText(userInput) - cliHubIntent := a.shouldExposeCLIHubIntentTools(userInput) + marketFollowUp := a.marketFollowUpIntent(userInput) + cliHubIntent := a.shouldExposeCLIHubIntentTools(userInput) || capabilityPlanUsesCLI(capabilityPlan) toolExposure := shouldExposeToolsForInput(query, allTools) skillInstructions := a.hasReadableSkillInstructions() - if !toolExposure && !cliHubIntent && !skillInstructions { + plannerExposure := capabilityPlanExposesTools(capabilityPlan) + if !toolExposure && !cliHubIntent && !skillInstructions && !plannerExposure && !marketFollowUp.ShouldExpose() { return nil } @@ -603,7 +635,10 @@ func (a *Agent) selectToolInfos(userInput string) []tools.ToolInfo { coreExact["read"] = struct{}{} coreExact["read_file"] = struct{}{} } + applyCapabilityPlanToolSelection(coreExact, capabilityPlan) + applyMarketFollowUpToolSelection(coreExact, marketFollowUp) prefixes := selectedToolPrefixesForIntent(query, allTools, intent) + prefixes = append(prefixes, selectedToolPrefixesForCapabilityPlan(capabilityPlan)...) appPrefixes := matchedToolPrefixes(query, allTools) prefixes = append(prefixes, appPrefixes...) @@ -639,6 +674,238 @@ func (a *Agent) hasReadableSkillInstructions() bool { return false } +func (a *Agent) planCapability(userInput string) *CapabilityPlan { + allTools := []tools.ToolInfo(nil) + if a.tools != nil { + allTools = a.visibleToolInfos() + } + input := CapabilityPlannerInput{ + UserInput: userInput, + Tools: allTools, + Skills: a.skillCatalogForCapabilityPlanner(), + CLICapabilities: a.cliCapabilitiesForCapabilityPlanner(), + LocalArtifacts: a.localArtifactsForCapabilityPlanner(), + } + plan := CapabilityPlanner{}.Plan(input) + return &plan +} + +func (a *Agent) skillCatalogForCapabilityPlanner() []skills.SkillCatalogEntry { + if a == nil || a.skills == nil { + return nil + } + return a.skills.Catalog() +} + +func (a *Agent) cliCapabilitiesForCapabilityPlanner() []clihub.Capability { + if a == nil { + return nil + } + root := strings.TrimSpace(firstNonEmpty(a.config.CLIHubRoot, a.workingDir, a.workDir)) + if root == "" { + return nil + } + reg, err := clihub.LoadCapabilityRegistry(root) + if err != nil || reg == nil { + return nil + } + return reg.All() +} + +func (a *Agent) localArtifactsForCapabilityPlanner() []CapabilityArtifactSummary { + if a == nil { + return nil + } + root := strings.TrimSpace(firstNonEmpty(a.workingDir, a.workDir)) + if root == "" { + return nil + } + store := marketplace.NewStore(root) + if _, err := os.Stat(store.ReceiptsDir()); err != nil { + return nil + } + receipts, err := store.ListReceipts() + if err != nil { + return nil + } + bound := map[string]bool{} + if _, err := os.Stat(store.BindingsDir()); err == nil { + if bindings, err := store.ListBindings(); err == nil { + for _, binding := range bindings.Items { + if binding.State == marketplace.BindingEnabled { + bound[strings.ToLower(strings.TrimSpace(binding.ArtifactID))] = true + } + } + } + } + items := make([]CapabilityArtifactSummary, 0, len(receipts)) + for _, receipt := range receipts { + id := strings.TrimSpace(receipt.ArtifactID) + if id == "" { + continue + } + capabilities := append([]string{receipt.Name, string(receipt.Kind)}, receipt.Permissions...) + capabilities = append(capabilities, receipt.RiskLevel, receipt.TrustLevel) + items = append(items, CapabilityArtifactSummary{ + ArtifactID: id, + Kind: string(receipt.Kind), + Name: receipt.Name, + Description: receipt.Description, + Capabilities: compactStrings(capabilities...), + Installed: true, + Bound: bound[strings.ToLower(id)], + }) + } + return items +} + +func capabilityPlanExposesTools(plan *CapabilityPlan) bool { + if plan == nil { + return false + } + switch plan.Route { + case CapabilityRouteUseSkill, CapabilityRouteDelegateAgent, CapabilityRouteUseCLI, CapabilityRouteSearchMarket: + return true + default: + return plan.ShouldExposeMarketSearch + } +} + +func capabilityPlanUsesCLI(plan *CapabilityPlan) bool { + return plan != nil && plan.Route == CapabilityRouteUseCLI +} + +func applyCapabilityPlanToolSelection(selected map[string]struct{}, plan *CapabilityPlan) { + if selected == nil || plan == nil { + return + } + add := func(names ...string) { + for _, name := range names { + selected[name] = struct{}{} + } + } + switch plan.Route { + case CapabilityRouteDelegateAgent: + add("delegate_task") + case CapabilityRouteUseCLI: + add("clihub_catalog", "clihub_exec", "intent_route", "intent_list_capabilities") + case CapabilityRouteSearchMarket: + if plan.ShouldExposeMarketSearch { + add("market_search_artifacts") + } + } +} + +func selectedToolPrefixesForCapabilityPlan(plan *CapabilityPlan) []string { + if plan == nil { + return nil + } + switch plan.Route { + case CapabilityRouteUseSkill: + if len(plan.LocalMatches) > 0 && strings.TrimSpace(plan.LocalMatches[0].Name) != "" { + return []string{"skill_" + strings.TrimSpace(plan.LocalMatches[0].Name)} + } + return []string{"skill_"} + default: + return nil + } +} + +type marketFollowUpIntent struct { + Install bool + Bind bool + ArtifactID string +} + +func (m marketFollowUpIntent) ShouldExpose() bool { + return m.Install || m.Bind +} + +func (a *Agent) marketFollowUpIntent(userInput string) marketFollowUpIntent { + combined := strings.TrimSpace(userInput + "\n" + a.recentConversationText(6)) + artifactID := firstArtifactID(combined) + if artifactID == "" { + return marketFollowUpIntent{} + } + query := normalizeCapabilityPlannerText(userInput) + raw := strings.ToLower(strings.TrimSpace(userInput)) + install := capabilityPlannerContainsAny(query, "install", "go ahead", "confirmed", "yes install", "安装", "确认安装", "同意安装", "可以安装", "继续安装") + bind := capabilityPlannerContainsAny(query, "bind", "activate", "enable", "use it", "绑定", "启用", "激活", "使用它") + if !install && !bind { + return marketFollowUpIntent{} + } + if strings.Contains(raw, "don't") || strings.Contains(raw, "do not") || strings.Contains(raw, "取消") || strings.Contains(raw, "不要") { + return marketFollowUpIntent{} + } + return marketFollowUpIntent{Install: install, Bind: bind, ArtifactID: artifactID} +} + +func (a *Agent) recentConversationText(limit int) string { + if a == nil || len(a.history) == 0 { + return "" + } + if limit <= 0 || limit > len(a.history) { + limit = len(a.history) + } + start := len(a.history) - limit + parts := make([]string, 0, limit) + for _, msg := range a.history[start:] { + if strings.TrimSpace(msg.Content) != "" { + parts = append(parts, msg.Content) + } + } + return strings.Join(parts, "\n") +} + +var artifactIDRegex = regexp.MustCompile(`(?i)\b[a-z0-9][a-z0-9_-]*(?:\.[a-z0-9][a-z0-9_-]*){1,}\b`) + +func firstArtifactID(text string) string { + for _, match := range artifactIDRegex.FindAllString(text, -1) { + match = strings.Trim(match, ".,;:()[]{}<>\"'`") + if isLikelyMarketplaceArtifactID(match) { + return match + } + } + return "" +} + +func isLikelyMarketplaceArtifactID(value string) bool { + parts := strings.Split(strings.ToLower(strings.TrimSpace(value)), ".") + if len(parts) < 2 { + return false + } + hasKind := false + hasAlpha := false + for _, part := range parts { + if part == "" { + return false + } + switch part { + case "agent", "skill", "cli", "plugin": + hasKind = true + } + for _, r := range part { + if r >= 'a' && r <= 'z' { + hasAlpha = true + break + } + } + } + return hasKind && hasAlpha +} + +func applyMarketFollowUpToolSelection(selected map[string]struct{}, intent marketFollowUpIntent) { + if selected == nil || !intent.ShouldExpose() { + return + } + if intent.Install { + selected["market_install_artifact"] = struct{}{} + } + if intent.Bind { + selected["market_bind_artifact"] = struct{}{} + } +} + func selectedCoreToolNames(query string, rawInput string, cliHubIntent bool) map[string]struct{} { return selectedCoreToolNamesForIntent(query, rawInput, classifyCodexToolIntent(query, rawInput, cliHubIntent)) } @@ -1415,7 +1682,7 @@ func (a *Agent) loadBootstrapFiles() ([]workspace.BootstrapFile, error) { return workspace.LoadBootstrapFiles(a.workingDir, workspace.BootstrapOptions{}) } -func (a *Agent) prepareSystemPrompt(ctx context.Context, selectedTools []tools.ToolInfo, toolDefs []llm.ToolDefinition) (string, error) { +func (a *Agent) prepareSystemPrompt(ctx context.Context, selectedTools []tools.ToolInfo, toolDefs []llm.ToolDefinition, capabilityPlan *CapabilityPlan) (string, error) { if a.contextRuntime != nil { compacted, err := a.contextRuntime.compactHistory(ctx, a.history, a.llm) if err != nil { @@ -1424,7 +1691,7 @@ func (a *Agent) prepareSystemPrompt(ctx context.Context, selectedTools []tools.T a.history = compacted } - systemPrompt, err := a.buildSystemPromptForToolInfos(selectedTools) + systemPrompt, err := a.buildSystemPromptForToolInfosWithPlan(selectedTools, capabilityPlan) if err != nil { return "", fmt.Errorf("failed to build system prompt: %w", err) } diff --git a/pkg/capability/agents/capability_planner.go b/pkg/capability/agents/capability_planner.go new file mode 100644 index 00000000..82cd73d1 --- /dev/null +++ b/pkg/capability/agents/capability_planner.go @@ -0,0 +1,338 @@ +package agent + +import ( + "fmt" + "sort" + "strings" + + "github.com/1024XEngineer/anyclaw/pkg/capability/skills" + "github.com/1024XEngineer/anyclaw/pkg/capability/tools" + "github.com/1024XEngineer/anyclaw/pkg/clihub" +) + +const ( + CapabilityTaskSimple = "simple" + CapabilityTaskLocalExecution = "local_execution" + CapabilityTaskComplex = "complex" + CapabilityTaskSpecialized = "specialized" + CapabilityTaskUnknown = "unknown" + + CapabilityRouteMainAgent = "main_agent" + CapabilityRouteUseSkill = "use_skill" + CapabilityRouteDelegateAgent = "delegate_agent" + CapabilityRouteUseCLI = "use_cli" + CapabilityRouteSearchMarket = "search_market" + CapabilityRouteNone = "none" +) + +type CapabilityPlanner struct{} + +type CapabilityPlannerInput struct { + UserInput string + Tools []tools.ToolInfo + Skills []skills.SkillCatalogEntry + Agents []CapabilityAgentSummary + CLICapabilities []clihub.Capability + LocalArtifacts []CapabilityArtifactSummary +} + +type CapabilityAgentSummary struct { + Name string + Description string + Domain string + Expertise []string + Skills []string + Tools []string +} + +type CapabilityArtifactSummary struct { + ArtifactID string + Kind string + Name string + Description string + Capabilities []string + Installed bool + Bound bool +} + +type CapabilityMatch struct { + Kind string `json:"kind"` + Name string `json:"name"` + Score float64 `json:"score"` + Reason string `json:"reason,omitempty"` +} + +type CapabilityPlan struct { + TaskClass string `json:"task_class"` + Route string `json:"route"` + Need string `json:"need,omitempty"` + KindHint string `json:"kind_hint,omitempty"` + LocalMatches []CapabilityMatch `json:"local_matches,omitempty"` + ShouldExposeMarketSearch bool `json:"should_expose_market_search,omitempty"` + Reason string `json:"reason,omitempty"` +} + +func (p CapabilityPlanner) Plan(input CapabilityPlannerInput) CapabilityPlan { + normalized := normalizeCapabilityPlannerText(input.UserInput) + if strings.TrimSpace(normalized) == "" { + return CapabilityPlan{TaskClass: CapabilityTaskUnknown, Route: CapabilityRouteNone, Reason: "empty request"} + } + if isSimpleCapabilityRequest(normalized) { + return CapabilityPlan{TaskClass: CapabilityTaskSimple, Route: CapabilityRouteMainAgent, Need: strings.TrimSpace(input.UserInput), Reason: "simple conversational request"} + } + + need := detectCapabilityPlannerNeed(input.UserInput) + if need == "" { + need = strings.TrimSpace(input.UserInput) + } + if match := bestSkillCapabilityMatch(normalized, input.Skills); match.Score >= 0.35 { + return CapabilityPlan{TaskClass: CapabilityTaskSpecialized, Route: CapabilityRouteUseSkill, Need: need, KindHint: "skill", LocalMatches: []CapabilityMatch{match}, Reason: "matching local skill is available"} + } + if match := bestAgentCapabilityMatch(normalized, input.Agents); match.Score >= 0.35 { + return CapabilityPlan{TaskClass: CapabilityTaskSpecialized, Route: CapabilityRouteDelegateAgent, Need: need, KindHint: "agent", LocalMatches: []CapabilityMatch{match}, Reason: "matching local agent is available"} + } + if match := bestCLICapabilityMatch(normalized, input.CLICapabilities); match.Score >= 0.35 { + return CapabilityPlan{TaskClass: CapabilityTaskLocalExecution, Route: CapabilityRouteUseCLI, Need: need, KindHint: "cli", LocalMatches: []CapabilityMatch{match}, Reason: "matching CLIHub capability is available"} + } + if match := bestArtifactCapabilityMatch(normalized, input.LocalArtifacts); match.Score >= 0.35 { + return CapabilityPlan{TaskClass: CapabilityTaskSpecialized, Route: routeForLocalArtifact(match.Kind), Need: need, KindHint: match.Kind, LocalMatches: []CapabilityMatch{match}, Reason: "matching installed marketplace artifact is available locally"} + } + + intent := classifyCodexToolIntent(normalized, input.UserInput, false) + if specialized, kindHint := inferSpecializedCapabilityNeed(normalized); specialized { + return CapabilityPlan{TaskClass: CapabilityTaskSpecialized, Route: CapabilityRouteSearchMarket, Need: need, KindHint: kindHint, ShouldExposeMarketSearch: true, Reason: "specialized capability is not available locally"} + } + if capabilityPlannerHasLocalExecutionIntent(normalized, intent) { + return CapabilityPlan{TaskClass: CapabilityTaskLocalExecution, Route: CapabilityRouteMainAgent, Need: need, Reason: "request fits built-in local execution tools"} + } + if capabilityPlannerLooksComplex(normalized) { + return CapabilityPlan{TaskClass: CapabilityTaskComplex, Route: CapabilityRouteMainAgent, Need: need, Reason: "complex task can start with main-agent planning and local tools"} + } + return CapabilityPlan{TaskClass: CapabilityTaskUnknown, Route: CapabilityRouteMainAgent, Need: need, Reason: "no specialized local or marketplace route required"} +} + +func detectCapabilityPlannerNeed(input string) string { + normalized := normalizeCapabilityPlannerText(input) + switch { + case capabilityPlannerContainsAny(normalized, "release notes", "changelog", "version notes"): + return "release notes" + case capabilityPlannerContainsAny(normalized, "code review", "review code", "pull request", "pr review"): + return "code review" + case capabilityPlannerContainsAny(normalized, "repo health", "repository health", "diagnose repo"): + return "repo health" + case capabilityPlannerContainsAny(normalized, "video render", "render video", "timeline", "shotcut", "premiere"): + return "video editing workflow" + case capabilityPlannerContainsAny(normalized, "spreadsheet", "excel", "presentation", "slides", "powerpoint"): + return "office document workflow" + default: + return strings.TrimSpace(input) + } +} + +func bestSkillCapabilityMatch(query string, items []skills.SkillCatalogEntry) CapabilityMatch { + var best CapabilityMatch + for _, item := range items { + haystack := strings.Join([]string{item.Name, item.FullName, item.Description, item.Category, item.Registry, item.Source, strings.Join(item.Permissions, " ")}, " ") + score := capabilityPlannerScore(query, haystack) + if score > best.Score { + best = CapabilityMatch{Kind: "skill", Name: firstCapabilityPlannerNonEmpty(item.Name, item.FullName), Score: score, Reason: item.Description} + } + } + return best +} + +func bestAgentCapabilityMatch(query string, items []CapabilityAgentSummary) CapabilityMatch { + var best CapabilityMatch + for _, item := range items { + haystack := strings.Join([]string{item.Name, item.Description, item.Domain, strings.Join(item.Expertise, " "), strings.Join(item.Skills, " "), strings.Join(item.Tools, " ")}, " ") + score := capabilityPlannerScore(query, haystack) + if score > best.Score { + best = CapabilityMatch{Kind: "agent", Name: item.Name, Score: score, Reason: firstCapabilityPlannerNonEmpty(item.Description, item.Domain)} + } + } + return best +} + +func bestCLICapabilityMatch(query string, items []clihub.Capability) CapabilityMatch { + var best CapabilityMatch + for _, item := range items { + haystack := strings.Join([]string{item.Harness, item.Group, item.Command, item.Action, item.Category, strings.Join(item.Keywords, " ")}, " ") + score := capabilityPlannerScore(query, haystack) + if score > best.Score { + best = CapabilityMatch{Kind: "cli", Name: strings.TrimSpace(item.Harness + " " + item.Command), Score: score, Reason: item.Action} + } + } + return best +} + +func bestArtifactCapabilityMatch(query string, items []CapabilityArtifactSummary) CapabilityMatch { + var best CapabilityMatch + for _, item := range items { + if !item.Installed { + continue + } + haystack := strings.Join([]string{item.ArtifactID, item.Kind, item.Name, item.Description, strings.Join(item.Capabilities, " ")}, " ") + score := capabilityPlannerScore(query, haystack) + if item.Bound { + score += 0.05 + } + if score > best.Score { + best = CapabilityMatch{Kind: item.Kind, Name: firstCapabilityPlannerNonEmpty(item.Name, item.ArtifactID), Score: score, Reason: item.Description} + } + } + return best +} + +func capabilityPlannerScore(query string, haystack string) float64 { + queryTerms := capabilityPlannerTerms(query) + if len(queryTerms) == 0 { + return 0 + } + haystack = normalizeCapabilityPlannerText(haystack) + if haystack == "" { + return 0 + } + matches := 0 + for _, term := range queryTerms { + if strings.Contains(haystack, term) { + matches++ + } + } + score := float64(matches) / float64(len(queryTerms)) + if strings.Contains(haystack, query) { + score += 0.25 + } + if score > 1 { + return 1 + } + return score +} + +func capabilityPlannerTerms(input string) []string { + input = normalizeCapabilityPlannerText(input) + terms := strings.Fields(input) + out := make([]string, 0, len(terms)) + seen := map[string]struct{}{} + for _, term := range terms { + if len(term) < 3 || capabilityPlannerStopWords[term] { + continue + } + if _, ok := seen[term]; ok { + continue + } + seen[term] = struct{}{} + out = append(out, term) + } + sort.Strings(out) + return out +} + +var capabilityPlannerStopWords = map[string]bool{ + "the": true, "and": true, "for": true, "with": true, "this": true, "that": true, + "please": true, "help": true, "need": true, "want": true, "make": true, "create": true, + "帮我": true, "请": true, +} + +func isSimpleCapabilityRequest(normalized string) bool { + if capabilityPlannerContainsAny(normalized, "who are you", "what are you", "thank you") || capabilityPlannerHasAnyWholeTerm(normalized, "hello", "hi", "thanks") { + return true + } + if specialized, _ := inferSpecializedCapabilityNeed(normalized); specialized { + return false + } + return len(strings.Fields(normalized)) <= 4 && !capabilityPlannerContainsAny(normalized, "create", "write", "edit", "run", "open", "install", "review", "render", "generate") +} + +func capabilityPlannerHasLocalExecutionIntent(query string, intent codexToolIntent) bool { + if intent.File || intent.Write || intent.Command || intent.Web || intent.Fetch || intent.Image || intent.Memory || intent.Plan || intent.Status || intent.Desktop || intent.Browser || intent.Automation { + return true + } + return capabilityPlannerContainsAny(query, "file", "folder", "command", "terminal", "website", "url", "local app", "desktop") +} + +func inferSpecializedCapabilityNeed(query string) (bool, string) { + switch { + case capabilityPlannerContainsAny(query, "code review", "pull request", "pr review", "security audit"): + return true, "agent" + case capabilityPlannerContainsAny(query, "release notes", "changelog", "version notes", "technical writing"): + return true, "skill" + case capabilityPlannerContainsAny(query, "repo health", "repository health", "dependency audit", "diagnose repository"): + return true, "cli" + case capabilityPlannerContainsAny(query, "shotcut", "premiere", "video timeline", "render video", "audio mastering"): + return true, "cli" + case capabilityPlannerContainsAny(query, "spreadsheet", "excel macro", "powerpoint", "slides", "presentation deck"): + return true, "skill" + default: + return false, "" + } +} + +func capabilityPlannerLooksComplex(query string) bool { + return strings.Count(query, " ") >= 12 || capabilityPlannerContainsAny(query, "architecture", "multi step", "end to end", "workflow", "pipeline", "orchestrate") +} + +func routeForLocalArtifact(kind string) string { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "agent": + return CapabilityRouteDelegateAgent + case "skill": + return CapabilityRouteUseSkill + case "cli": + return CapabilityRouteUseCLI + default: + return CapabilityRouteMainAgent + } +} + +func normalizeCapabilityPlannerText(input string) string { + replacer := strings.NewReplacer("_", " ", "-", " ", "/", " ", `\`, " ", ".", " ", ":", " ", ",", " ", ";", " ", "?", " ", "!", " ", "\n", " ", "\t", " ") + return strings.Join(strings.Fields(strings.ToLower(strings.TrimSpace(replacer.Replace(input)))), " ") +} + +func capabilityPlannerContainsAny(value string, needles ...string) bool { + value = normalizeCapabilityPlannerText(value) + for _, needle := range needles { + needle = normalizeCapabilityPlannerText(needle) + if needle != "" && strings.Contains(value, needle) { + return true + } + } + return false +} + +func capabilityPlannerHasAnyWholeTerm(value string, terms ...string) bool { + fields := strings.Fields(normalizeCapabilityPlannerText(value)) + seen := make(map[string]struct{}, len(fields)) + for _, field := range fields { + seen[field] = struct{}{} + } + for _, term := range terms { + if _, ok := seen[normalizeCapabilityPlannerText(term)]; ok { + return true + } + } + return false +} + +func firstCapabilityPlannerNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func (p CapabilityPlan) Summary() string { + parts := []string{fmt.Sprintf("%s via %s", p.TaskClass, p.Route)} + if p.Need != "" { + parts = append(parts, "need="+p.Need) + } + if p.KindHint != "" { + parts = append(parts, "kind="+p.KindHint) + } + if p.Reason != "" { + parts = append(parts, p.Reason) + } + return strings.Join(parts, "; ") +} diff --git a/pkg/capability/agents/capability_planner_test.go b/pkg/capability/agents/capability_planner_test.go new file mode 100644 index 00000000..c4a7a65f --- /dev/null +++ b/pkg/capability/agents/capability_planner_test.go @@ -0,0 +1,100 @@ +package agent + +import ( + "testing" + + "github.com/1024XEngineer/anyclaw/pkg/capability/skills" + "github.com/1024XEngineer/anyclaw/pkg/capability/tools" + "github.com/1024XEngineer/anyclaw/pkg/clihub" +) + +func TestCapabilityPlannerPrefersLocalSkill(t *testing.T) { + plan := CapabilityPlanner{}.Plan(CapabilityPlannerInput{ + UserInput: "write release notes for this changelog", + Skills: []skills.SkillCatalogEntry{{ + Name: "release-notes", + Description: "Draft release notes and changelog summaries.", + Category: "writing", + Installed: true, + }}, + }) + if plan.Route != CapabilityRouteUseSkill || plan.KindHint != "skill" || len(plan.LocalMatches) != 1 { + t.Fatalf("plan = %#v, want local skill route", plan) + } + if plan.ShouldExposeMarketSearch { + t.Fatalf("local skill should avoid market search: %#v", plan) + } +} + +func TestCapabilityPlannerPrefersLocalAgentAndCLI(t *testing.T) { + agentPlan := CapabilityPlanner{}.Plan(CapabilityPlannerInput{ + UserInput: "review this pull request for code risks", + Agents: []CapabilityAgentSummary{{ + Name: "code-reviewer", + Description: "Reviews pull requests and code risks.", + Domain: "code review", + Expertise: []string{"pull request", "security review"}, + }}, + }) + if agentPlan.Route != CapabilityRouteDelegateAgent || agentPlan.KindHint != "agent" { + t.Fatalf("agent plan = %#v, want delegation", agentPlan) + } + + cliPlan := CapabilityPlanner{}.Plan(CapabilityPlannerInput{ + UserInput: "render the shotcut video timeline", + CLICapabilities: []clihub.Capability{{ + Harness: "shotcut", + Group: "Timeline", + Command: "render", + Action: "Timeline render", + Category: "video", + Keywords: []string{"video", "timeline", "render", "shotcut"}, + }}, + }) + if cliPlan.Route != CapabilityRouteUseCLI || cliPlan.KindHint != "cli" { + t.Fatalf("cli plan = %#v, want CLIHub route", cliPlan) + } +} + +func TestCapabilityPlannerSpecializedGapExposesMarketSearch(t *testing.T) { + plan := CapabilityPlanner{}.Plan(CapabilityPlannerInput{ + UserInput: "review this pull request for security and maintainability", + }) + if plan.Route != CapabilityRouteSearchMarket || !plan.ShouldExposeMarketSearch || plan.KindHint != "agent" { + t.Fatalf("plan = %#v, want market search for missing specialized agent", plan) + } +} + +func TestCapabilityPlannerLocalExecutionDoesNotExposeMarketSearch(t *testing.T) { + plan := CapabilityPlanner{}.Plan(CapabilityPlannerInput{ + UserInput: "read the README and run go test", + Tools: []tools.ToolInfo{ + {Name: "read_file"}, + {Name: "run_command"}, + }, + }) + if plan.Route != CapabilityRouteMainAgent || plan.TaskClass != CapabilityTaskLocalExecution { + t.Fatalf("plan = %#v, want local execution main agent route", plan) + } + if plan.ShouldExposeMarketSearch { + t.Fatalf("local execution should not expose market search: %#v", plan) + } +} + +func TestCapabilityPlannerUsesInstalledLocalMarketplaceArtifact(t *testing.T) { + plan := CapabilityPlanner{}.Plan(CapabilityPlannerInput{ + UserInput: "diagnose repository health", + LocalArtifacts: []CapabilityArtifactSummary{{ + ArtifactID: "cloud.cli.repo-health", + Kind: "cli", + Name: "Repo Health", + Description: "Diagnose repository health.", + Capabilities: []string{"repo health", "diagnose repository"}, + Installed: true, + Bound: true, + }}, + }) + if plan.Route != CapabilityRouteUseCLI || plan.KindHint != "cli" || len(plan.LocalMatches) != 1 { + t.Fatalf("plan = %#v, want installed local marketplace cli route", plan) + } +} diff --git a/pkg/capability/agents/prompt/prompt.go b/pkg/capability/agents/prompt/prompt.go index 4e783b8a..480797a1 100644 --- a/pkg/capability/agents/prompt/prompt.go +++ b/pkg/capability/agents/prompt/prompt.go @@ -40,6 +40,9 @@ func (b *SystemPromptBuilder) Build(data PromptData) (string, error) { if capabilities := b.buildCapabilities(data); capabilities != "" { parts = append(parts, capabilities) } + if capabilityPlan := b.buildCapabilityPlanning(data); capabilityPlan != "" { + parts = append(parts, capabilityPlan) + } if operatingMode := b.buildOperatingMode(data); operatingMode != "" { parts = append(parts, operatingMode) } @@ -124,6 +127,52 @@ func (b *SystemPromptBuilder) buildCapabilities(data PromptData) string { return strings.Join(parts, "\n") } +func (b *SystemPromptBuilder) buildCapabilityPlanning(data PromptData) string { + plan := data.CapabilityPlan + if strings.TrimSpace(plan.TaskClass) == "" || strings.TrimSpace(plan.Route) == "" { + return "" + } + lines := []string{ + "## Capability Planning", + fmt.Sprintf("- Task class: %s.", plan.TaskClass), + fmt.Sprintf("- Planned route: %s.", plan.Route), + } + if strings.TrimSpace(plan.Need) != "" { + lines = append(lines, "- Capability need: "+trimPromptLine(plan.Need, 180)+".") + } + if strings.TrimSpace(plan.KindHint) != "" { + lines = append(lines, "- Preferred capability kind: "+trimPromptLine(plan.KindHint, 80)+".") + } + if strings.TrimSpace(plan.TopLocalMatch.Name) != "" { + match := plan.TopLocalMatch + line := fmt.Sprintf("- Best local match: %s/%s", trimPromptLine(match.Kind, 60), trimPromptLine(match.Name, 100)) + if match.Score > 0 { + line += fmt.Sprintf(" (score %.2f)", match.Score) + } + if strings.TrimSpace(match.Reason) != "" { + line += ": " + trimPromptLine(match.Reason, 180) + } + lines = append(lines, line+".") + } + if strings.TrimSpace(plan.Reason) != "" { + lines = append(lines, "- Reason: "+trimPromptLine(plan.Reason, 220)+".") + } + if plan.ShouldExposeMarketSearch { + lines = append(lines, + "- If local tools and skills are insufficient, use market_search_artifacts to search installed and cloud marketplace artifacts for this missing capability.", + "- Do not call market_install_artifact or market_bind_artifact unless the user explicitly confirms the proposed artifact and any required risk acknowledgement.", + ) + } + if hasTool(data.Tools, "market_install_artifact") || hasTool(data.Tools, "market_bind_artifact") { + lines = append(lines, + "- Marketplace install/bind tools are visible because this turn appears to be a follow-up confirmation.", + "- Call market_install_artifact only after explicit user confirmation. If it returns requires_confirmation, explain the policy decision and ask for the missing confirmation or risk acknowledgement.", + "- Call market_bind_artifact only after an artifact is installed and the user has confirmed where it should be bound.", + ) + } + return strings.Join(lines, "\n") +} + func (b *SystemPromptBuilder) buildClawBridge(data PromptData) string { if data.ClawBridge == nil { return "" @@ -482,6 +531,24 @@ type PromptData struct { ClawBridge *ClawBridgeInfo WorkspaceFiles []WorkspaceFile History []Message + CapabilityPlan CapabilityPlanInfo +} + +type CapabilityPlanInfo struct { + TaskClass string + Route string + Need string + KindHint string + TopLocalMatch CapabilityMatchInfo + ShouldExposeMarketSearch bool + Reason string +} + +type CapabilityMatchInfo struct { + Kind string + Name string + Score float64 + Reason string } type AvailableSkill struct { @@ -589,6 +656,17 @@ func formatToolNameList(tools []ToolInfo, limit int) string { return strings.Join(names, ", ") } +func trimPromptLine(value string, limit int) string { + value = strings.Join(strings.Fields(strings.TrimSpace(value)), " ") + if limit <= 0 || len(value) <= limit { + return value + } + if limit <= 1 { + return value[:limit] + } + return value[:limit-1] + "..." +} + func hasTool(tools []ToolInfo, name string) bool { for _, tool := range tools { if strings.EqualFold(strings.TrimSpace(tool.Name), strings.TrimSpace(name)) { diff --git a/pkg/capability/agents/prompt/prompt_test.go b/pkg/capability/agents/prompt/prompt_test.go new file mode 100644 index 00000000..affd5203 --- /dev/null +++ b/pkg/capability/agents/prompt/prompt_test.go @@ -0,0 +1,40 @@ +package prompt + +import ( + "strings" + "testing" +) + +func TestBuildSystemPromptIncludesCapabilityPlanning(t *testing.T) { + out, err := BuildSystemPrompt("Ava", "Default description", PromptData{ + Description: "Override description", + CapabilityPlan: CapabilityPlanInfo{ + TaskClass: "specialized", + Route: "search_market", + Need: strings.Repeat("find release-note helper ", 12), + KindHint: "skill", + TopLocalMatch: CapabilityMatchInfo{Kind: "skill", Name: "release-notes", Score: 0.87, Reason: "matches docs"}, + Reason: "local tools are insufficient", + ShouldExposeMarketSearch: true, + }, + Tools: []ToolInfo{ + {Name: "market_search_artifacts", Description: "Search market"}, + {Name: "market_install_artifact", Description: "Install market item"}, + }, + }) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "Override description", + "## Capability Planning", + "search_market", + "market_search_artifacts", + "market_install_artifact only after explicit user confirmation", + "release-notes", + } { + if !strings.Contains(out, want) { + t.Fatalf("prompt missing %q:\n%s", want, out) + } + } +} diff --git a/pkg/capability/markettools/marketplace.go b/pkg/capability/markettools/marketplace.go index 98ff6269..eaa05695 100644 --- a/pkg/capability/markettools/marketplace.go +++ b/pkg/capability/markettools/marketplace.go @@ -8,20 +8,16 @@ import ( "github.com/1024XEngineer/anyclaw/pkg/capability/tools" "github.com/1024XEngineer/anyclaw/pkg/marketplace" - marketregistry "github.com/1024XEngineer/anyclaw/pkg/marketplace/registry" + marketbridge "github.com/1024XEngineer/anyclaw/pkg/marketplace/bridge" ) type Options struct { - Store *marketplace.Store - Registry *marketregistry.Client - AutoInstallSkill bool - AuditLogger tools.AuditLogger - AfterInstall func(ctx context.Context, receipt *marketplace.InstallReceipt) error - AfterBind func(ctx context.Context, binding *marketplace.Binding) error + Bridge marketbridge.Bridge + AuditLogger tools.AuditLogger } func Register(registry *tools.Registry, opts Options) { - if registry == nil || opts.Store == nil { + if registry == nil || opts.Bridge == nil { return } registry.Register(&tools.Tool{ @@ -104,39 +100,26 @@ func searchArtifacts(ctx context.Context, opts Options, input map[string]any) (s kind := marketplace.NormalizeKind(stringValue(input["kind"])) source := marketplace.NormalizeSource(stringValue(input["source"])) limit := intValue(input["limit"], 5) - local, err := localArtifacts(opts.Store, kind, limit) + result, err := opts.Bridge.Search(ctx, marketbridge.SearchRequest{Query: query, Kind: kind, Source: source, Limit: limit}) if err != nil { return "", err } - var cloud []marketplace.Artifact - var cloudErr string - if source != marketplace.SourceLocal && opts.Registry != nil { - result, err := opts.Registry.List(ctx, marketplace.Filter{Kind: kind, Query: query, Limit: limit}) - if err != nil { - cloudErr = err.Error() - } else { - cloud = result.Items - } - } - if source == marketplace.SourceCloud { - local = nil - } - route := marketplace.RouteCapabilityNeed(query, local, cloud, limit) + route := marketplace.RouteCapabilityNeed(query, result.Local, result.Cloud, limit) return marshalJSON(map[string]any{ "query": query, "kind": kind, "source": firstNonEmpty(string(source), "all"), "route": route, - "local_count": len(local), - "cloud_count": len(cloud), - "local": marketplace.BuildCapabilityIndex(local), - "cloud": marketplace.BuildCapabilityIndex(cloud), - "cloud_error": cloudErr, + "local_count": len(result.Local), + "cloud_count": len(result.Cloud), + "local": marketplace.BuildCapabilityIndex(result.Local), + "cloud": marketplace.BuildCapabilityIndex(result.Cloud), + "cloud_error": result.CloudErr, }) } func installArtifact(ctx context.Context, opts Options, input map[string]any) (string, error) { - if opts.Store == nil || opts.Registry == nil { + if opts.Bridge == nil { return "", fmt.Errorf("marketplace install is not configured") } artifactID := strings.TrimSpace(stringValue(input["artifact_id"])) @@ -146,61 +129,26 @@ func installArtifact(ctx context.Context, opts Options, input map[string]any) (s version := strings.TrimSpace(stringValue(input["version_constraint"])) userConfirmed := boolValue(input["user_confirmed"]) riskAcknowledged := boolValue(input["risk_acknowledged"]) - resolved, err := opts.Registry.Resolve(ctx, artifactID, marketregistry.ResolveRequest{VersionConstraint: version}) - if err != nil { - return "", err - } - resolvedPkg := resolvedPackage(resolved) - decision := marketplace.NewDecisionPolicy(marketplace.PolicyConfig{AutoInstallSkill: opts.AutoInstallSkill}).DecideInstall(marketplace.InstallRequest{ + req := marketplace.InstallRequest{ ArtifactID: artifactID, VersionConstraint: version, InstalledBy: "agent", UserConfirmed: userConfirmed, RiskAcknowledged: riskAcknowledged, - }, resolvedPkg) - if decision.Decision == marketplace.DecisionAsk && (decision.RequiresUserConfirmation || decision.RequiresRiskAcknowledgement) { - _ = opts.Store.AppendAudit(marketplace.MarketAuditEvent{ - Type: "market.agent_install.ask", - ArtifactID: artifactID, - Actor: "agent", - Decision: string(decision.Decision), - Reason: decision.Reason, - Detail: map[string]any{ - "version": resolved.Version, - "risk_level": resolved.RiskLevel, - "trust_level": resolved.TrustLevel, - "permissions": resolved.Permissions, - }, - }) - return marshalJSON(map[string]any{"status": "requires_confirmation", "decision": decision, "artifact": resolvedPkg}) } - uc := marketplace.NewInstallUseCaseWithPolicy(opts.Store, registryAdapter{client: opts.Registry}, marketplace.PolicyConfig{AutoInstallSkill: opts.AutoInstallSkill}) - job, reused, err := uc.Start(ctx, marketplace.InstallRequest{ - ArtifactID: artifactID, - VersionConstraint: version, - InstalledBy: "agent", - UserConfirmed: userConfirmed, - RiskAcknowledged: riskAcknowledged, - IdempotencyKey: "agent-" + artifactID + "-" + resolved.Version, - }) + plan, err := opts.Bridge.PlanInstall(ctx, req) if err != nil { return "", err } - if !reused { - if err := uc.Execute(ctx, job.ID); err != nil { - latest, _ := opts.Store.GetJob(job.ID) - return marshalJSON(map[string]any{"status": "failed", "job": latest, "error": err.Error()}) - } + if plan.Decision.Decision == marketplace.DecisionAsk && (plan.Decision.RequiresUserConfirmation || plan.Decision.RequiresRiskAcknowledgement) { + return marshalJSON(map[string]any{"status": "requires_confirmation", "decision": plan.Decision, "artifact": plan.Artifact}) } - latest, _ := opts.Store.GetJob(job.ID) - if latest != nil && latest.State == marketplace.JobSucceeded && strings.TrimSpace(latest.ReceiptID) != "" && opts.AfterInstall != nil { - if receipt, receiptErr := opts.Store.GetReceipt(latest.ReceiptID); receiptErr == nil { - if hookErr := opts.AfterInstall(ctx, receipt); hookErr != nil { - return marshalJSON(map[string]any{"status": "installed", "job": latest, "reused": reused, "integration_error": hookErr.Error()}) - } - } + req.IdempotencyKey = "agent-" + artifactID + "-" + plan.Artifact.Version + result, err := opts.Bridge.Install(ctx, req) + if err != nil { + return marshalJSON(map[string]any{"status": "failed", "job": result.Job, "error": err.Error()}) } - return marshalJSON(map[string]any{"status": "installed", "job": latest, "reused": reused}) + return marshalJSON(map[string]any{"status": "installed", "job": result.Job, "reused": result.Reused}) } func bindArtifact(ctx context.Context, opts Options, input map[string]any) (string, error) { @@ -209,7 +157,10 @@ func bindArtifact(ctx context.Context, opts Options, input map[string]any) (stri if artifactID == "" || targetType == "" { return "", fmt.Errorf("artifact_id and target_type are required") } - binding, err := opts.Store.CreateBinding(marketplace.BindingRequest{ + if opts.Bridge == nil { + return "", fmt.Errorf("marketplace bridge is not configured") + } + binding, err := opts.Bridge.Bind(ctx, marketplace.BindingRequest{ ArtifactID: artifactID, TargetType: targetType, TargetID: strings.TrimSpace(stringValue(input["target_id"])), @@ -217,93 +168,9 @@ func bindArtifact(ctx context.Context, opts Options, input map[string]any) (stri if err != nil { return "", err } - if opts.AfterBind != nil { - if err := opts.AfterBind(ctx, binding); err != nil { - return marshalJSON(map[string]any{"status": "bound", "binding": binding, "refresh_error": err.Error()}) - } - } - _ = opts.Store.AppendAudit(marketplace.MarketAuditEvent{ - Type: "market.agent_bind.succeeded", - ArtifactID: binding.ArtifactID, - BindingID: binding.ID, - Actor: "agent", - Detail: map[string]any{ - "target_type": binding.TargetType, - "target_id": binding.TargetID, - "version": binding.Version, - }, - }) return marshalJSON(map[string]any{"status": "bound", "binding": binding}) } -func localArtifacts(store *marketplace.Store, kind marketplace.ArtifactKind, limit int) ([]marketplace.Artifact, error) { - receipts, err := store.ListReceipts() - if err != nil { - return nil, err - } - items := make([]marketplace.Artifact, 0, len(receipts)) - for _, receipt := range receipts { - if kind != "" && receipt.Kind != kind { - continue - } - items = append(items, marketplace.Artifact{ - ID: receipt.ArtifactID, - Kind: receipt.Kind, - Name: receipt.Name, - DisplayName: receipt.Name, - Version: receipt.Version, - Source: marketplace.SourceLocal, - Status: marketplace.StatusInstalled, - Installed: true, - Enabled: true, - Permissions: append([]string(nil), receipt.Permissions...), - RiskLevel: receipt.RiskLevel, - TrustLevel: receipt.TrustLevel, - Compatibility: receipt.Compatibility, - Dependencies: append([]marketplace.ArtifactDependency(nil), receipt.Dependencies...), - Capabilities: []string{receipt.Name, string(receipt.Kind)}, - }) - if limit > 0 && len(items) >= limit { - break - } - } - return items, nil -} - -type registryAdapter struct { - client *marketregistry.Client -} - -func (a registryAdapter) Resolve(ctx context.Context, artifactID, versionConstraint string) (marketplace.ResolvedPackage, error) { - resolved, err := a.client.Resolve(ctx, artifactID, marketregistry.ResolveRequest{VersionConstraint: versionConstraint}) - if err != nil { - return marketplace.ResolvedPackage{}, err - } - return resolvedPackage(resolved), nil -} - -func (a registryAdapter) Download(ctx context.Context, rawURL string) ([]byte, error) { - return a.client.Download(ctx, rawURL) -} - -func resolvedPackage(resolved marketregistry.ResolvedArtifact) marketplace.ResolvedPackage { - return marketplace.ResolvedPackage{ - ArtifactID: resolved.ArtifactID, - Version: resolved.Version, - DownloadURL: resolved.DownloadURL, - ChecksumSHA256: resolved.ChecksumSHA256, - SizeBytes: resolved.SizeBytes, - Compatibility: resolved.Compatibility, - Dependencies: resolved.Dependencies, - RiskLevel: resolved.RiskLevel, - TrustLevel: resolved.TrustLevel, - Permissions: append([]string(nil), resolved.Permissions...), - Signature: resolved.Signature, - Kind: resolved.Kind, - Name: resolved.Name, - } -} - func audit(opts Options, toolName string, input map[string]any, next tools.ToolFunc) tools.ToolFunc { return func(ctx context.Context, _ map[string]any) (string, error) { output, err := next(ctx, input) diff --git a/pkg/capability/markettools/marketplace_test.go b/pkg/capability/markettools/marketplace_test.go index a9261ce4..a63e0677 100644 --- a/pkg/capability/markettools/marketplace_test.go +++ b/pkg/capability/markettools/marketplace_test.go @@ -14,12 +14,13 @@ import ( "github.com/1024XEngineer/anyclaw/pkg/capability/tools" "github.com/1024XEngineer/anyclaw/pkg/marketplace" + marketbridge "github.com/1024XEngineer/anyclaw/pkg/marketplace/bridge" marketregistry "github.com/1024XEngineer/anyclaw/pkg/marketplace/registry" ) func TestRegisterMarketplaceToolsMainAgentOnly(t *testing.T) { registry := tools.NewRegistry() - Register(registry, Options{Store: marketplace.NewStore(t.TempDir())}) + Register(registry, Options{Bridge: testBridge(t, marketplace.NewStore(t.TempDir()), nil)}) if _, ok := registry.Get("market_search_artifacts"); !ok { t.Fatal("expected market_search_artifacts tool") } @@ -40,8 +41,7 @@ func TestSearchToolRoutesMissingCapabilityToCloud(t *testing.T) { registry := tools.NewRegistry() Register(registry, Options{ - Store: marketplace.NewStore(t.TempDir()), - Registry: marketregistry.NewClient(marketregistry.ClientConfig{Endpoint: server.URL}), + Bridge: testBridge(t, marketplace.NewStore(t.TempDir()), marketregistry.NewClient(marketregistry.ClientConfig{Endpoint: server.URL})), }) out, err := registry.Call(tools.WithToolCaller(context.Background(), tools.ToolCaller{Role: tools.ToolCallerRoleMainAgent}), "market_search_artifacts", map[string]any{ "query": "please write release notes", @@ -62,7 +62,7 @@ func TestInstallToolAskReturnsConfirmationWithoutInstalling(t *testing.T) { store := marketplace.NewStore(t.TempDir()) registry := tools.NewRegistry() - Register(registry, Options{Store: store, Registry: marketregistry.NewClient(marketregistry.ClientConfig{Endpoint: server.URL})}) + Register(registry, Options{Bridge: testBridge(t, store, marketregistry.NewClient(marketregistry.ClientConfig{Endpoint: server.URL}))}) out, err := registry.Call(tools.WithToolCaller(context.Background(), tools.ToolCaller{Role: tools.ToolCallerRoleMainAgent}), "market_install_artifact", map[string]any{ "artifact_id": "cloud.agent.code-reviewer", }) @@ -84,7 +84,7 @@ func TestInstallToolConfirmedInstallsAsAgent(t *testing.T) { store := marketplace.NewStore(t.TempDir()) registry := tools.NewRegistry() - Register(registry, Options{Store: store, Registry: marketregistry.NewClient(marketregistry.ClientConfig{Endpoint: server.URL}), AutoInstallSkill: true}) + Register(registry, Options{Bridge: testBridgeWithAutoInstall(t, store, marketregistry.NewClient(marketregistry.ClientConfig{Endpoint: server.URL}))}) out, err := registry.Call(tools.WithToolCaller(context.Background(), tools.ToolCaller{Role: tools.ToolCallerRoleMainAgent}), "market_install_artifact", map[string]any{ "artifact_id": "cloud.skill.release-notes", }) @@ -119,7 +119,7 @@ func TestBindToolCreatesAgentBinding(t *testing.T) { t.Fatal(err) } registry := tools.NewRegistry() - Register(registry, Options{Store: store}) + Register(registry, Options{Bridge: testBridge(t, store, nil)}) out, err := registry.Call(tools.WithToolCaller(context.Background(), tools.ToolCaller{Role: tools.ToolCallerRoleMainAgent}), "market_bind_artifact", map[string]any{ "artifact_id": "cloud.skill.release-notes", "target_type": "runtime_global", @@ -136,10 +136,10 @@ func TestMarketToolsValidationAndHelpers(t *testing.T) { if _, err := installArtifact(context.Background(), Options{}, map[string]any{"artifact_id": "x"}); err == nil { t.Fatal("expected install not configured error") } - if _, err := installArtifact(context.Background(), Options{Store: marketplace.NewStore(t.TempDir()), Registry: marketregistry.NewClient(marketregistry.ClientConfig{Endpoint: "http://127.0.0.1:1"})}, map[string]any{}); err == nil || !strings.Contains(err.Error(), "artifact_id is required") { + if _, err := installArtifact(context.Background(), Options{Bridge: testBridge(t, marketplace.NewStore(t.TempDir()), marketregistry.NewClient(marketregistry.ClientConfig{Endpoint: "http://127.0.0.1:1"}))}, map[string]any{}); err == nil || !strings.Contains(err.Error(), "artifact_id is required") { t.Fatalf("expected artifact id error, got %v", err) } - if _, err := bindArtifact(context.Background(), Options{Store: marketplace.NewStore(t.TempDir())}, map[string]any{"artifact_id": "x"}); err == nil || !strings.Contains(err.Error(), "artifact_id and target_type") { + if _, err := bindArtifact(context.Background(), Options{Bridge: testBridge(t, marketplace.NewStore(t.TempDir()), nil)}, map[string]any{"artifact_id": "x"}); err == nil || !strings.Contains(err.Error(), "artifact_id and target_type") { t.Fatalf("expected bind validation error, got %v", err) } if stringValue(123) != "123" || !boolValue("true") || boolValue("false") || intValue(float64(7), 1) != 7 || intValue("bad", 9) != 9 { @@ -173,8 +173,7 @@ func TestSearchArtifactsCloudOnlyAndLocalLimit(t *testing.T) { t.Fatal(err) } out, err := searchArtifacts(context.Background(), Options{ - Store: store, - Registry: marketregistry.NewClient(marketregistry.ClientConfig{Endpoint: server.URL}), + Bridge: testBridge(t, store, marketregistry.NewClient(marketregistry.ClientConfig{Endpoint: server.URL})), }, map[string]any{"query": "release", "kind": "skill", "source": "cloud", "limit": 1}) if err != nil { t.Fatal(err) @@ -182,15 +181,25 @@ func TestSearchArtifactsCloudOnlyAndLocalLimit(t *testing.T) { if strings.Contains(out, "Local Skill") || !strings.Contains(out, "cloud.skill.release-notes") { t.Fatalf("unexpected cloud-only search output: %s", out) } - local, err := localArtifacts(store, marketplace.ArtifactKindSkill, 1) + result, err := testBridge(t, store, nil).Search(context.Background(), marketbridge.SearchRequest{Kind: marketplace.ArtifactKindSkill, Limit: 1}) if err != nil { t.Fatal(err) } - if len(local) != 1 || local[0].ID != "local.skill" { - t.Fatalf("unexpected local artifacts: %#v", local) + if len(result.Local) != 1 || result.Local[0].ID != "local.skill" { + t.Fatalf("unexpected local artifacts: %#v", result.Local) } } +func testBridge(t *testing.T, store *marketplace.Store, registry *marketregistry.Client) *marketbridge.DefaultBridge { + t.Helper() + return marketbridge.New(marketbridge.Options{Store: store, Registry: registry}) +} + +func testBridgeWithAutoInstall(t *testing.T, store *marketplace.Store, registry *marketregistry.Client) *marketbridge.DefaultBridge { + t.Helper() + return marketbridge.New(marketbridge.Options{Store: store, Registry: registry, AutoInstallSkill: true}) +} + func toolListed(items []tools.ToolInfo, name string) bool { for _, item := range items { if item.Name == name { diff --git a/pkg/gateway/gateway_market_artifacts_api.go b/pkg/gateway/gateway_market_artifacts_api.go index e7e7fb3c..463fab2f 100644 --- a/pkg/gateway/gateway_market_artifacts_api.go +++ b/pkg/gateway/gateway_market_artifacts_api.go @@ -37,35 +37,31 @@ func (s *Server) handleMarketArtifacts(w http.ResponseWriter, r *http.Request) { Offset: parseIntParam(r.URL.Query().Get("offset"), 0), } if filter.Source == marketplace.SourceCloud { - result, cloudErr := s.listCloudMarketArtifacts(r, filter) - if cloudErr != "" { + list, err := s.marketplaceBridge().List(r.Context(), filter) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if list.CloudErr != "" { writeJSON(w, http.StatusOK, map[string]any{ - "data": result, + "data": list.Result, "meta": map[string]any{ - "cloud_error": cloudErr, + "cloud_error": list.CloudErr, }, }) return } - writeJSON(w, http.StatusOK, map[string]any{"data": result}) + writeJSON(w, http.StatusOK, map[string]any{"data": list.Result}) return } - catalog := marketplace.NewLocalCatalog(marketplace.LocalCatalogDeps{ - Config: s.mainRuntime.Config, - Skills: s.mainRuntime.Skills, - Plugins: s.plugins, - AgentStore: s.storeModule, - CLIHub: s.loadCLIHubCatalog(), - }) - result, err := catalog.List(r.Context(), filter) + list, err := s.marketplaceBridge().List(r.Context(), filter) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } - result.Items = s.marketplaceStore().OverlayStatus(result.Items) writeJSON(w, http.StatusOK, map[string]any{ - "data": result, + "data": list.Result, }) } @@ -87,7 +83,7 @@ func (s *Server) handleMarketArtifactDetail(w http.ResponseWriter, r *http.Reque if s.shouldUseCloudMarketArtifact(r, id) { if versions { - items, err := s.cloudMarketVersions(r, id) + items, err := s.marketplaceBridge().Versions(r.Context(), id, marketplace.SourceCloud) if err != nil { if errors.Is(err, marketregistry.ErrNotConfigured) || errors.Is(err, marketregistry.ErrRemoteDisabled) { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "cloud registry unavailable"}) @@ -104,7 +100,7 @@ func (s *Server) handleMarketArtifactDetail(w http.ResponseWriter, r *http.Reque return } - artifact, err := s.cloudMarketArtifact(r, id) + artifact, err := s.marketplaceBridge().Get(r.Context(), id, marketplace.SourceCloud) if err != nil { if errors.Is(err, marketregistry.ErrNotConfigured) || errors.Is(err, marketregistry.ErrRemoteDisabled) { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "cloud registry unavailable"}) @@ -117,18 +113,12 @@ func (s *Server) handleMarketArtifactDetail(w http.ResponseWriter, r *http.Reque writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) return } - overlaid := s.marketplaceStore().OverlayStatus([]marketplace.Artifact{*artifact}) - if len(overlaid) > 0 { - artifact = &overlaid[0] - } writeJSON(w, http.StatusOK, map[string]any{"data": artifact}) return } - catalog := s.localMarketCatalog() - if versions { - items, err := catalog.Versions(r.Context(), id) + items, err := s.marketplaceBridge().Versions(r.Context(), id, marketplace.SourceLocal) if err != nil { if errors.Is(err, marketplace.ErrArtifactNotFound) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "artifact not found"}) @@ -141,7 +131,7 @@ func (s *Server) handleMarketArtifactDetail(w http.ResponseWriter, r *http.Reque return } - artifact, err := catalog.Get(r.Context(), id) + artifact, err := s.marketplaceBridge().Get(r.Context(), id, marketplace.SourceLocal) if err != nil { if errors.Is(err, marketplace.ErrArtifactNotFound) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "artifact not found"}) @@ -151,10 +141,6 @@ func (s *Server) handleMarketArtifactDetail(w http.ResponseWriter, r *http.Reque return } - overlaid := s.marketplaceStore().OverlayStatus([]marketplace.Artifact{*artifact}) - if len(overlaid) > 0 { - artifact = &overlaid[0] - } writeJSON(w, http.StatusOK, map[string]any{"data": artifact}) } @@ -182,55 +168,19 @@ func (s *Server) cloudRegistryClient() *marketregistry.Client { } func (s *Server) listCloudMarketArtifacts(r *http.Request, filter marketplace.Filter) (marketplace.ListResult, string) { - client := s.cloudRegistryClient() - if client == nil { - return emptyMarketList(filter), "cloud registry endpoint is not configured" - } - filter.Source = "" - result, err := client.List(r.Context(), filter) + result, err := s.marketplaceBridge().List(r.Context(), filter) if err != nil { return emptyMarketList(filter), err.Error() } - s.overlayCloudMarketStatus(r, &result, filter) - applyMarketStatusFilter(&result, filter.Status) - return result, "" -} - -func (s *Server) overlayCloudMarketStatus(r *http.Request, result *marketplace.ListResult, filter marketplace.Filter) { - if s == nil || result == nil || len(result.Items) == 0 { - return - } - result.Items = s.marketplaceStore().OverlayStatus(result.Items) -} - -func applyMarketStatusFilter(result *marketplace.ListResult, status marketplace.ArtifactStatus) { - if result == nil || status == "" { - return - } - items := result.Items[:0] - for _, item := range result.Items { - if item.Status == status { - items = append(items, item) - } - } - result.Items = items - result.Total = len(items) + return result.Result, result.CloudErr } func (s *Server) cloudMarketArtifact(r *http.Request, id string) (*marketplace.Artifact, error) { - client := s.cloudRegistryClient() - if client == nil { - return nil, marketregistry.ErrNotConfigured - } - return client.Get(r.Context(), id) + return s.marketplaceBridge().Get(r.Context(), id, marketplace.SourceCloud) } func (s *Server) cloudMarketVersions(r *http.Request, id string) ([]marketplace.ArtifactVersion, error) { - client := s.cloudRegistryClient() - if client == nil { - return nil, marketregistry.ErrNotConfigured - } - return client.Versions(r.Context(), id) + return s.marketplaceBridge().Versions(r.Context(), id, marketplace.SourceCloud) } func (s *Server) shouldUseCloudMarketArtifact(r *http.Request, id string) bool { diff --git a/pkg/gateway/gateway_market_bindings_api.go b/pkg/gateway/gateway_market_bindings_api.go new file mode 100644 index 00000000..7c690df9 --- /dev/null +++ b/pkg/gateway/gateway_market_bindings_api.go @@ -0,0 +1,198 @@ +package gateway + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/1024XEngineer/anyclaw/pkg/marketplace" + "github.com/1024XEngineer/anyclaw/pkg/runtime" +) + +func (s *Server) handleMarketBindings(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + result, err := s.marketplaceBridge().ListBindings() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": result}) + case http.MethodPost: + var req marketplace.BindingRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + normalized, err := s.normalizeMarketBindingRequest(req) + if err != nil { + status := http.StatusBadRequest + if err == errMarketRuntimeUnavailable { + status = http.StatusServiceUnavailable + } + writeJSON(w, status, map[string]string{"error": err.Error()}) + return + } + binding, err := s.marketplaceBridge().Bind(r.Context(), normalized) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": binding}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *Server) handleMarketBindingByID(w http.ResponseWriter, r *http.Request) { + id := strings.Trim(strings.TrimPrefix(r.URL.Path, "/market/bindings/"), "/") + if id == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "binding id required"}) + return + } + switch r.Method { + case http.MethodDelete: + if _, err := s.marketplaceBridge().DeleteBinding(r.Context(), id); err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "binding not found"}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *Server) handleMarketEvents(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + result, err := s.marketplaceBridge().ListEvents(parseIntParam(r.URL.Query().Get("limit"), 100)) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (s *Server) handleMarketRefresh(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + Scope string `json:"scope,omitempty"` + Agent string `json:"agent,omitempty"` + Org string `json:"org,omitempty"` + Project string `json:"project,omitempty"` + Workspace string `json:"workspace,omitempty"` + SessionID string `json:"session_id,omitempty"` + Warm bool `json:"warm,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + scopeKind := runtime.RefreshScopeKind(strings.TrimSpace(req.Scope)) + if scopeKind == "" && strings.TrimSpace(req.SessionID) != "" { + scopeKind = runtime.RefreshScopeSession + } + agent, orgID, projectID, workspaceID := req.Agent, req.Org, req.Project, req.Workspace + if scopeKind != runtime.RefreshScopeSession { + agent, orgID, projectID, workspaceID = s.marketRefreshTarget(req.Agent, req.Org, req.Project, req.Workspace) + } + result := s.marketHotReload().Refresh(r.Context(), runtime.RefreshScope{ + Kind: scopeKind, + Agent: agent, + Org: orgID, + Project: projectID, + Workspace: workspaceID, + SessionID: req.SessionID, + Reason: "market.manual_refresh", + Warm: req.Warm, + }) + if result.Status == "failed" { + writeJSON(w, http.StatusBadRequest, map[string]any{"status": "failed", "error": result.Error, "result": result}) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "refreshed", + "agent": agent, + "org": orgID, + "project": projectID, + "workspace": workspaceID, + "result": result, + }) +} + +func (s *Server) normalizeMarketBindingRequest(req marketplace.BindingRequest) (marketplace.BindingRequest, error) { + if s == nil || s.mainRuntime == nil || s.mainRuntime.Config == nil { + return req, errMarketRuntimeUnavailable + } + req.ArtifactID = strings.TrimSpace(req.ArtifactID) + req.ReceiptID = strings.TrimSpace(req.ReceiptID) + req.TargetType = marketplace.NormalizeBindingTargetType(string(req.TargetType)) + req.TargetID = strings.TrimSpace(req.TargetID) + if req.TargetType == "" { + return req, errString("invalid target_type") + } + if req.TargetType == marketplace.TargetMainAgent { + req.TargetID = strings.TrimSpace(s.mainRuntime.Config.ResolveMainAgentName()) + if req.TargetID == "" { + req.TargetID = strings.TrimSpace(s.mainRuntime.Config.Agent.Name) + } + } + if req.TargetType == marketplace.TargetWorkspace && req.TargetID == "" { + _, _, workspaceID := defaultResourceIDs(s.mainRuntime.WorkingDir) + req.TargetID = workspaceID + } + return req, nil +} + +func marketActor(r *http.Request) string { + if user := UserFromContext(r.Context()); user != nil && strings.TrimSpace(user.Name) != "" { + return user.Name + } + return "user" +} + +func (s *Server) marketRefreshTarget(agent, orgID, projectID, workspaceID string) (string, string, string, string) { + if s == nil || s.mainRuntime == nil { + return agent, orgID, projectID, workspaceID + } + if strings.TrimSpace(agent) == "" && s.mainRuntime.Config != nil { + agent = strings.TrimSpace(s.mainRuntime.Config.ResolveMainAgentName()) + if agent == "" { + agent = strings.TrimSpace(s.mainRuntime.Config.Agent.Name) + } + } + defaultOrg, defaultProject, defaultWorkspace := defaultResourceIDs(s.mainRuntime.WorkingDir) + if strings.TrimSpace(orgID) == "" { + orgID = defaultOrg + } + if strings.TrimSpace(projectID) == "" { + projectID = defaultProject + } + if strings.TrimSpace(workspaceID) == "" { + workspaceID = defaultWorkspace + } + return agent, orgID, projectID, workspaceID +} + +type errString string + +func (e errString) Error() string { + return string(e) +} + +var errMarketRuntimeUnavailable = errString("runtime config is unavailable") + +func (s *Server) marketHotReload() *runtime.HotReloadCoordinator { + if s == nil { + return nil + } + if s.hotReload == nil { + s.hotReload = runtime.NewHotReloadCoordinator(s.runtimePool, s.store) + } + return s.hotReload +} diff --git a/pkg/gateway/gateway_market_bindings_api_test.go b/pkg/gateway/gateway_market_bindings_api_test.go new file mode 100644 index 00000000..92817117 --- /dev/null +++ b/pkg/gateway/gateway_market_bindings_api_test.go @@ -0,0 +1,103 @@ +package gateway + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/1024XEngineer/anyclaw/pkg/marketplace" +) + +func TestMarketBindingCreateDeleteAndRefresh(t *testing.T) { + server := newGatewayMarketTestServer(t, "") + receipt := &marketplace.InstallReceipt{ + ID: "cloud.agent.code-reviewer@1.0.0", + ArtifactID: "cloud.agent.code-reviewer", + Kind: marketplace.ArtifactKindAgent, + Name: "Code Reviewer", + Version: "1.0.0", + Source: marketplace.SourceCloud, + InstalledPath: t.TempDir(), + InstalledBy: "user", + InstalledAt: time.Now().UTC().Format(time.RFC3339), + } + if err := server.marketplaceStore().SaveReceipt(receipt); err != nil { + t.Fatal(err) + } + + rec := httptest.NewRecorder() + server.handleMarketBindings(rec, httptest.NewRequest(http.MethodPost, "/market/bindings", strings.NewReader(`{"artifact_id":"cloud.agent.code-reviewer","target_type":"main_agent"}`))) + if rec.Code != http.StatusOK { + t.Fatalf("binding status = %d body=%s", rec.Code, rec.Body.String()) + } + var payload struct { + Data marketplace.Binding `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload.Data.TargetID != "Main Agent" { + t.Fatalf("target id = %q, want Main Agent", payload.Data.TargetID) + } + if metrics := server.runtimePool.Metrics(); metrics.Refreshes != 1 { + t.Fatalf("expected one refresh, got %+v", metrics) + } + + rec = httptest.NewRecorder() + server.handleMarketBindings(rec, httptest.NewRequest(http.MethodGet, "/market/bindings", nil)) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), payload.Data.ID) { + t.Fatalf("list status = %d body=%s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + server.handleMarketBindingByID(rec, httptest.NewRequest(http.MethodDelete, "/market/bindings/"+payload.Data.ID, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("delete status = %d body=%s", rec.Code, rec.Body.String()) + } + if metrics := server.runtimePool.Metrics(); metrics.Refreshes != 2 { + t.Fatalf("expected delete refresh, got %+v", metrics) + } +} + +func TestMarketRefreshAndBindingValidation(t *testing.T) { + server := newGatewayMarketTestServer(t, "") + + rec := httptest.NewRecorder() + server.handleMarketRefresh(rec, httptest.NewRequest(http.MethodPost, "/market/refresh", strings.NewReader(`{}`))) + if rec.Code != http.StatusOK { + t.Fatalf("refresh status = %d body=%s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + server.handleMarketBindings(rec, httptest.NewRequest(http.MethodPost, "/market/bindings", strings.NewReader(`{"artifact_id":"x","target_type":"unknown"}`))) + if rec.Code != http.StatusBadRequest { + t.Fatalf("invalid binding status = %d body=%s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + server.handleMarketBindingByID(rec, httptest.NewRequest(http.MethodDelete, "/market/bindings/missing", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("missing delete status = %d body=%s", rec.Code, rec.Body.String()) + } + + agent, orgID, projectID, workspaceID := (*Server)(nil).marketRefreshTarget("a", "o", "p", "w") + if agent != "a" || orgID != "o" || projectID != "p" || workspaceID != "w" { + t.Fatalf("nil server changed target values: %q %q %q %q", agent, orgID, projectID, workspaceID) + } +} + +func TestMarketBindingCreateReturnsUnavailableWithoutRuntime(t *testing.T) { + server := &Server{marketJobs: marketplace.NewStore(t.TempDir())} + + rec := httptest.NewRecorder() + server.handleMarketBindings(rec, httptest.NewRequest(http.MethodPost, "/market/bindings", strings.NewReader(`{"artifact_id":"x","target_type":"main_agent"}`))) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("binding status = %d body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "runtime config is unavailable") { + t.Fatalf("binding body = %s", rec.Body.String()) + } +} diff --git a/pkg/gateway/gateway_market_install_api.go b/pkg/gateway/gateway_market_install_api.go new file mode 100644 index 00000000..c046b1ea --- /dev/null +++ b/pkg/gateway/gateway_market_install_api.go @@ -0,0 +1,147 @@ +package gateway + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/1024XEngineer/anyclaw/pkg/marketplace" + marketbridge "github.com/1024XEngineer/anyclaw/pkg/marketplace/bridge" +) + +func (s *Server) handleMarketInstall(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if s == nil || s.mainRuntime == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "runtime not available"}) + return + } + var req marketplace.InstallRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + req.IdempotencyKey = r.Header.Get("Idempotency-Key") + if strings.TrimSpace(req.InstalledBy) == "" { + req.InstalledBy = marketActor(r) + } + result, err := s.marketplaceBridge().StartInstall(r.Context(), req) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if !result.Reused && result.Job != nil && !isTerminalMarketJob(result.Job.State) { + s.enqueueMarketJob(result.Job.ID) + } + writeJSON(w, marketJobStatus(result), map[string]any{"job_id": result.Job.ID, "job": result.Job, "reused": result.Reused}) +} + +func (s *Server) handleMarketUpgrade(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if s == nil || s.mainRuntime == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "runtime not available"}) + return + } + var req marketplace.UpgradeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + req.IdempotencyKey = r.Header.Get("Idempotency-Key") + if strings.TrimSpace(req.InstalledBy) == "" { + req.InstalledBy = marketActor(r) + } + result, err := s.marketplaceBridge().StartUpgrade(r.Context(), req) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if !result.Reused && result.Job != nil && !isTerminalMarketJob(result.Job.State) { + s.enqueueMarketJob(result.Job.ID) + } + writeJSON(w, marketJobStatus(result), map[string]any{"job_id": result.Job.ID, "job": result.Job, "reused": result.Reused}) +} + +func (s *Server) handleMarketUninstall(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if s == nil || s.mainRuntime == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "runtime not available"}) + return + } + var req marketplace.UninstallRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + if strings.TrimSpace(req.Actor) == "" { + req.Actor = marketActor(r) + } + result, err := s.marketplaceBridge().Uninstall(r.Context(), req) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (s *Server) handleMarketJobs(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + result, err := s.marketplaceBridge().ListJobs(parseIntParam(r.URL.Query().Get("limit"), 100)) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (s *Server) handleMarketJobDetail(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + id := strings.Trim(strings.TrimPrefix(r.URL.Path, "/market/jobs/"), "/") + if id == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "job id required"}) + return + } + job, err := s.marketplaceBridge().GetJob(id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "job not found"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": job}) +} + +func (s *Server) enqueueMarketJob(jobID string) { + run := func() { + _, _ = s.marketplaceBridge().ExecuteJob(context.Background(), jobID) + } + if s != nil && s.jobQueue != nil { + s.jobQueue <- run + return + } + go run() +} + +func marketJobStatus(result marketbridge.InstallResult) int { + if result.Job != nil && isTerminalMarketJob(result.Job.State) { + return http.StatusOK + } + return http.StatusAccepted +} + +func isTerminalMarketJob(state marketplace.JobState) bool { + return state == marketplace.JobSucceeded || state == marketplace.JobFailed || state == marketplace.JobRolledBack || state == marketplace.JobCanceled +} diff --git a/pkg/gateway/gateway_market_install_api_test.go b/pkg/gateway/gateway_market_install_api_test.go new file mode 100644 index 00000000..465f65b6 --- /dev/null +++ b/pkg/gateway/gateway_market_install_api_test.go @@ -0,0 +1,338 @@ +package gateway + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/1024XEngineer/anyclaw/pkg/config" + "github.com/1024XEngineer/anyclaw/pkg/marketplace" + appRuntime "github.com/1024XEngineer/anyclaw/pkg/runtime" + "github.com/1024XEngineer/anyclaw/pkg/state" +) + +func TestMarketInstallCreatesJobAndReceiptThroughBridge(t *testing.T) { + archive := testGatewayMarketArchive(t, "cloud.skill.release-notes", marketplace.ArtifactKindSkill, "1.0.0") + registry := testGatewayMarketRegistry(t, "cloud.skill.release-notes", marketplace.ArtifactKindSkill, "1.0.0", "low", "verified", []string{"fs.read"}, archive) + defer registry.Close() + + server := newGatewayMarketTestServer(t, registry.URL) + runQueuedMarketJobs(t, server) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/market/install", strings.NewReader(`{"artifact_id":"cloud.skill.release-notes","user_confirmed":true}`)) + req.Header.Set("Idempotency-Key", "install-1") + server.handleMarketInstall(rec, req) + if rec.Code != http.StatusAccepted { + t.Fatalf("install status = %d body=%s", rec.Code, rec.Body.String()) + } + var payload struct { + JobID string `json:"job_id"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + job := waitGatewayMarketJob(t, server, payload.JobID) + if job.State != marketplace.JobSucceeded || job.ReceiptID == "" { + t.Fatalf("job = %#v, want succeeded with receipt", job) + } + + rec = httptest.NewRecorder() + server.handleMarketJobDetail(rec, httptest.NewRequest(http.MethodGet, "/market/jobs/"+payload.JobID, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("job detail status = %d body=%s", rec.Code, rec.Body.String()) + } + rec = httptest.NewRecorder() + server.handleMarketJobs(rec, httptest.NewRequest(http.MethodGet, "/market/jobs", nil)) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), payload.JobID) { + t.Fatalf("jobs status = %d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestMarketInstallPolicyRequiresAcknowledgement(t *testing.T) { + archive := testGatewayMarketArchive(t, "cloud.skill.shell-helper", marketplace.ArtifactKindSkill, "1.0.0") + registry := testGatewayMarketRegistry(t, "cloud.skill.shell-helper", marketplace.ArtifactKindSkill, "1.0.0", "low", "verified", []string{"process.exec"}, archive) + defer registry.Close() + + server := newGatewayMarketTestServer(t, registry.URL) + runQueuedMarketJobs(t, server) + + rec := httptest.NewRecorder() + server.handleMarketInstall(rec, httptest.NewRequest(http.MethodPost, "/market/install", strings.NewReader(`{"artifact_id":"cloud.skill.shell-helper","user_confirmed":true}`))) + if rec.Code != http.StatusAccepted { + t.Fatalf("install status = %d body=%s", rec.Code, rec.Body.String()) + } + var payload struct { + JobID string `json:"job_id"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + job := waitGatewayMarketJob(t, server, payload.JobID) + if job.State != marketplace.JobFailed || job.Decision == nil || !job.Decision.RequiresRiskAcknowledgement { + t.Fatalf("job = %#v, want failed high-risk acknowledgement", job) + } + + rec = httptest.NewRecorder() + server.handleMarketEvents(rec, httptest.NewRequest(http.MethodGet, "/market/events", nil)) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "market.policy.decision") { + t.Fatalf("events status = %d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestMarketUpgradeAndUninstallUseBridge(t *testing.T) { + archive := testGatewayMarketArchive(t, "cloud.skill.release-notes", marketplace.ArtifactKindSkill, "2.0.0") + registry := testGatewayMarketRegistry(t, "cloud.skill.release-notes", marketplace.ArtifactKindSkill, "2.0.0", "low", "verified", []string{"fs.read"}, archive) + defer registry.Close() + + server := newGatewayMarketTestServer(t, registry.URL) + runner := runQueuedMarketJobs(t, server) + receipt := &marketplace.InstallReceipt{ + ID: "cloud.skill.release-notes@1.0.0", + ArtifactID: "cloud.skill.release-notes", + Kind: marketplace.ArtifactKindSkill, + Name: "Release Notes", + Version: "1.0.0", + Source: marketplace.SourceCloud, + InstalledPath: filepath.Join(server.marketplaceStore().InstalledDir(), "skill", "cloud-skill-release-notes", "1-0-0"), + InstalledBy: "user", + InstalledAt: time.Now().UTC().Format(time.RFC3339), + } + if err := os.MkdirAll(receipt.InstalledPath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(receipt.InstalledPath, "skill"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(receipt.InstalledPath, "anyclaw.artifact.json"), []byte(`{"id":"cloud.skill.release-notes","kind":"skill","name":"Release Notes","version":"1.0.0"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(receipt.InstalledPath, "skill", "SKILL.md"), []byte("# Release Notes\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := server.marketplaceStore().SaveReceipt(receipt); err != nil { + t.Fatal(err) + } + if err := server.mainRuntime.IntegrateMarketReceiptAndRefresh(context.Background(), receipt); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(server.mainRuntime.Config.Skills.Dir, "cloud-skill-release-notes")); err != nil { + t.Fatalf("expected integrated skill dir: %v", err) + } + binding, err := server.marketplaceStore().CreateBinding(marketplace.BindingRequest{ + ArtifactID: receipt.ArtifactID, + ReceiptID: receipt.ID, + TargetType: marketplace.TargetRuntimeGlobal, + }) + if err != nil { + t.Fatal(err) + } + + rec := httptest.NewRecorder() + server.handleMarketUpgrade(rec, httptest.NewRequest(http.MethodPost, "/market/upgrade", strings.NewReader(`{"artifact_id":"cloud.skill.release-notes","user_confirmed":true}`))) + if rec.Code != http.StatusAccepted { + t.Fatalf("upgrade status = %d body=%s", rec.Code, rec.Body.String()) + } + var upgradePayload struct { + JobID string `json:"job_id"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &upgradePayload); err != nil { + t.Fatal(err) + } + job := waitGatewayMarketJob(t, server, upgradePayload.JobID) + runner.Wait(t) + if job.State != marketplace.JobSucceeded || job.Version != "2.0.0" { + t.Fatalf("job = %#v, want upgraded to 2.0.0", job) + } + bindings, err := server.marketplaceStore().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" { + t.Fatalf("bindings = %#v, want upgraded binding", bindings.Items) + } + + rec = httptest.NewRecorder() + server.handleMarketUninstall(rec, httptest.NewRequest(http.MethodPost, "/market/uninstall", strings.NewReader(`{"artifact_id":"cloud.skill.release-notes"}`))) + if rec.Code != http.StatusOK { + t.Fatalf("uninstall status = %d body=%s", rec.Code, rec.Body.String()) + } + var uninstallPayload struct { + Data marketplace.UninstallResult `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &uninstallPayload); err != nil { + t.Fatal(err) + } + if _, err := server.marketplaceStore().GetReceipt(uninstallPayload.Data.ReceiptID); err != marketplace.ErrArtifactNotFound { + t.Fatalf("uninstalled receipt err = %v, want not found", err) + } + if bindings, err := server.marketplaceStore().ListBindings(); err != nil || len(bindings.Items) != 0 { + t.Fatalf("bindings after uninstall = %#v err=%v, want removed", bindings.Items, err) + } + if _, err := os.Stat(filepath.Join(server.mainRuntime.Config.Skills.Dir, "cloud-skill-release-notes")); !os.IsNotExist(err) { + t.Fatalf("integrated skill dir err = %v, want not exist", err) + } +} + +func testGatewayMarketRegistry(t *testing.T, id string, kind marketplace.ArtifactKind, version, risk, trust string, permissions []string, archive []byte) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/resolve"): + writeRegistryJSON(t, w, map[string]any{"data": map[string]any{ + "artifact_id": id, + "version": version, + "download_url": "http://" + r.Host + "/v1/download/" + id + "/" + version, + "checksum_sha256": gatewayMarketSHA256(archive), + "size_bytes": len(archive), + "risk_level": risk, + "trust_level": trust, + "permissions": permissions, + "kind": kind, + "name": id, + }}) + case strings.Contains(r.URL.Path, "/v1/download/"): + _, _ = w.Write(archive) + default: + http.NotFound(w, r) + } + })) +} + +func testGatewayMarketArchive(t *testing.T, id string, kind marketplace.ArtifactKind, version string) []byte { + t.Helper() + var buf bytes.Buffer + writer := zip.NewWriter(&buf) + w, err := writer.Create("anyclaw.artifact.json") + if err != nil { + t.Fatal(err) + } + data, _ := json.Marshal(map[string]any{"id": id, "kind": kind, "name": id, "version": version}) + if _, err := w.Write(data); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func gatewayMarketSHA256(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func newGatewayMarketTestServer(t *testing.T, endpoint string) *Server { + t.Helper() + workDir := t.TempDir() + cfg := config.DefaultConfig() + cfg.Agent.Name = "Main Agent" + cfg.Agent.ActiveProfile = "Main Agent" + cfg.Agent.Profiles = []config.AgentProfile{{Name: "Main Agent", PermissionLevel: "limited"}} + cfg.Agent.WorkDir = workDir + cfg.Agent.WorkingDir = workDir + cfg.Skills.Dir = filepath.Join(workDir, "skills") + cfg.Marketplace.RegistryEndpoint = endpoint + cfg.Marketplace.RequestTimeoutSeconds = 2 + cfg.Marketplace.CacheTTLSeconds = 0 + if err := cfg.Save(filepath.Join(workDir, "anyclaw.json")); err != nil { + t.Fatal(err) + } + store, err := state.NewStore(workDir) + if err != nil { + t.Fatal(err) + } + rt := &appRuntime.MainRuntime{ + Config: cfg, + ConfigPath: filepath.Join(workDir, "anyclaw.json"), + WorkDir: workDir, + WorkingDir: workDir, + } + server := &Server{ + mainRuntime: rt, + store: store, + runtimePool: appRuntime.NewRuntimePool(rt.ConfigPath, store, 4, time.Minute), + jobQueue: make(chan func(), 16), + marketJobs: marketplace.NewStore(workDir), + } + server.sessions = state.NewSessionManager(store, nil) + server.hotReload = appRuntime.NewHotReloadCoordinator(server.runtimePool, store) + rt.HotReload = server.hotReload + return server +} + +type gatewayMarketJobRunner struct { + done chan struct{} + completed chan struct{} + stopped chan struct{} +} + +func runQueuedMarketJobs(t *testing.T, server *Server) *gatewayMarketJobRunner { + t.Helper() + runner := &gatewayMarketJobRunner{ + done: make(chan struct{}), + completed: make(chan struct{}, 16), + stopped: make(chan struct{}), + } + go func() { + defer close(runner.stopped) + for { + select { + case job := <-server.jobQueue: + job() + select { + case runner.completed <- struct{}{}: + case <-runner.done: + return + } + case <-runner.done: + return + } + } + }() + t.Cleanup(func() { + close(runner.done) + <-runner.stopped + }) + return runner +} + +func (r *gatewayMarketJobRunner) Wait(t *testing.T) { + t.Helper() + if r == nil { + return + } + select { + case <-r.completed: + case <-time.After(5 * time.Second): + t.Fatal("queued market job did not finish") + } +} + +func waitGatewayMarketJob(t *testing.T, server *Server, jobID string) *marketplace.InstallJob { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + job, err := server.marketplaceStore().GetJob(jobID) + if err == nil && isTerminalMarketJob(job.State) { + return job + } + time.Sleep(20 * time.Millisecond) + } + job, err := server.marketplaceStore().GetJob(jobID) + if err != nil { + t.Fatal(err) + } + t.Fatalf("job did not finish: %#v", job) + return nil +} diff --git a/pkg/gateway/gateway_market_store.go b/pkg/gateway/gateway_market_store.go index 4fe28815..705acdcf 100644 --- a/pkg/gateway/gateway_market_store.go +++ b/pkg/gateway/gateway_market_store.go @@ -1,9 +1,12 @@ package gateway import ( + "context" "strings" "github.com/1024XEngineer/anyclaw/pkg/marketplace" + marketbridge "github.com/1024XEngineer/anyclaw/pkg/marketplace/bridge" + "github.com/1024XEngineer/anyclaw/pkg/runtime" ) func (s *Server) marketplaceStore() *marketplace.Store { @@ -19,3 +22,52 @@ func (s *Server) marketplaceStore() *marketplace.Store { } return s.marketJobs } + +func (s *Server) marketplaceBridge() marketbridge.Bridge { + if s == nil { + return nil + } + return marketbridge.New(marketbridge.Options{ + Store: s.marketplaceStore(), + Registry: s.cloudRegistryClient(), + LocalCatalog: s.localMarketCatalog(), + AfterInstall: func(ctx context.Context, receipt *marketplace.InstallReceipt) error { + if s.mainRuntime == nil { + return nil + } + return s.mainRuntime.IntegrateMarketReceiptAndRefresh(ctx, receipt) + }, + AfterBind: func(ctx context.Context, binding *marketplace.Binding) error { + if s.mainRuntime == nil { + return nil + } + return s.mainRuntime.RefreshAfterMarketBinding(ctx, binding) + }, + BeforeUninstall: func(ctx context.Context, receipt *marketplace.InstallReceipt) error { + if s.mainRuntime == nil { + return nil + } + return s.mainRuntime.CleanupMarketReceiptAndRefresh(ctx, receipt) + }, + AfterUninstall: func(ctx context.Context, result *marketplace.UninstallResult) error { + return s.refreshAfterMarketUninstall(ctx) + }, + }) +} + +func (s *Server) refreshAfterMarketUninstall(ctx context.Context) error { + if s == nil || s.mainRuntime == nil { + return nil + } + if s.mainRuntime.HotReload != nil { + result := s.mainRuntime.HotReload.Refresh(ctx, runtime.RefreshScope{Kind: runtime.RefreshScopeGlobal, Reason: "market.uninstall"}) + if result.Status == "failed" { + return errString(result.Error) + } + return nil + } + if err := s.mainRuntime.RefreshToolRegistry(); err != nil { + return err + } + return nil +} diff --git a/pkg/gateway/gateway_routes_platform.go b/pkg/gateway/gateway_routes_platform.go index 5bf482b6..c92a5b78 100644 --- a/pkg/gateway/gateway_routes_platform.go +++ b/pkg/gateway/gateway_routes_platform.go @@ -64,6 +64,21 @@ func (s *Server) registerMCPRoutes(mux *http.ServeMux) { func (s *Server) registerMarketRoutes(mux *http.ServeMux) { mux.HandleFunc("/market/artifacts", s.wrap("/market/artifacts", requirePermission("market.read", s.handleMarketArtifacts))) mux.HandleFunc("/market/artifacts/", s.wrap("/market/artifacts/", requirePermission("market.read", s.handleMarketArtifactDetail))) + mux.HandleFunc("/market/install", s.wrap("/market/install", requirePermission("market.write", s.handleMarketInstall))) + mux.HandleFunc("/market/upgrade", s.wrap("/market/upgrade", requirePermission("market.write", s.handleMarketUpgrade))) + mux.HandleFunc("/market/uninstall", s.wrap("/market/uninstall", requirePermission("market.write", s.handleMarketUninstall))) + mux.HandleFunc("/market/jobs", s.wrap("/market/jobs", requirePermission("market.read", s.handleMarketJobs))) + mux.HandleFunc("/market/jobs/", s.wrap("/market/jobs/", requirePermission("market.read", s.handleMarketJobDetail))) + mux.HandleFunc("/market/events", s.wrap("/market/events", requirePermission("market.read", s.handleMarketEvents))) + mux.HandleFunc("/market/bindings", s.wrap("/market/bindings", requirePermissionByMethod(map[string]string{ + http.MethodGet: "market.read", + http.MethodPost: "market.write", + }, "market.read", s.handleMarketBindings))) + mux.HandleFunc("/market/bindings/", s.wrap("/market/bindings/", requirePermissionByMethod(map[string]string{ + http.MethodDelete: "market.write", + http.MethodGet: "market.read", + }, "market.read", s.handleMarketBindingByID))) + mux.HandleFunc("/market/refresh", s.wrap("/market/refresh", requirePermission("market.write", s.handleMarketRefresh))) mux.HandleFunc("/market/search", s.wrap("/market/search", requirePermission("market.read", s.handleMarketSearch))) mux.HandleFunc("/market/plugins", s.wrap("/market/plugins", requirePermission("market.read", s.handleMarketPlugins))) mux.HandleFunc("/market/plugins/", s.wrap("/market/plugins/", requirePermissionByMethod(map[string]string{ diff --git a/pkg/marketplace/bridge/bridge.go b/pkg/marketplace/bridge/bridge.go new file mode 100644 index 00000000..62cadfd4 --- /dev/null +++ b/pkg/marketplace/bridge/bridge.go @@ -0,0 +1,576 @@ +package bridge + +import ( + "context" + "fmt" + "strings" + + "github.com/1024XEngineer/anyclaw/pkg/marketplace" + marketregistry "github.com/1024XEngineer/anyclaw/pkg/marketplace/registry" +) + +type Bridge interface { + Search(ctx context.Context, req SearchRequest) (SearchResult, error) + List(ctx context.Context, filter marketplace.Filter) (ListResult, error) + Get(ctx context.Context, artifactID string, source marketplace.SourceKind) (*marketplace.Artifact, error) + Versions(ctx context.Context, artifactID string, source marketplace.SourceKind) ([]marketplace.ArtifactVersion, error) + Resolve(ctx context.Context, artifactID, versionConstraint string) (marketplace.ResolvedPackage, error) + PlanInstall(ctx context.Context, req marketplace.InstallRequest) (InstallPlan, error) + StartInstall(ctx context.Context, req marketplace.InstallRequest) (InstallResult, error) + Install(ctx context.Context, req marketplace.InstallRequest) (InstallResult, error) + StartUpgrade(ctx context.Context, req marketplace.UpgradeRequest) (InstallResult, error) + ExecuteJob(ctx context.Context, jobID string) (*marketplace.InstallJob, error) + ListJobs(limit int) (marketplace.JobListResult, error) + GetJob(jobID string) (*marketplace.InstallJob, error) + Bind(ctx context.Context, req marketplace.BindingRequest) (*marketplace.Binding, error) + ListBindings() (marketplace.BindingListResult, error) + DeleteBinding(ctx context.Context, bindingID string) (*marketplace.Binding, error) + Uninstall(ctx context.Context, req marketplace.UninstallRequest) (*marketplace.UninstallResult, error) + ListEvents(limit int) (marketplace.MarketEventListResult, error) +} + +type SearchRequest struct { + Query string + Kind marketplace.ArtifactKind + Source marketplace.SourceKind + Limit int +} + +type SearchResult struct { + Local []marketplace.Artifact + Cloud []marketplace.Artifact + CloudErr string +} + +type ListResult struct { + Result marketplace.ListResult + CloudErr string +} + +type InstallResult struct { + Job *marketplace.InstallJob + Reused bool +} + +type InstallPlan struct { + Request marketplace.InstallRequest `json:"request"` + Artifact marketplace.ResolvedPackage `json:"artifact"` + Decision marketplace.PolicyDecision `json:"decision"` +} + +type Options struct { + Store *marketplace.Store + Registry *marketregistry.Client + LocalCatalog *marketplace.LocalCatalog + AutoInstallSkill bool + AfterInstall func(context.Context, *marketplace.InstallReceipt) error + AfterBind func(context.Context, *marketplace.Binding) error + BeforeUninstall func(context.Context, *marketplace.InstallReceipt) error + AfterUninstall func(context.Context, *marketplace.UninstallResult) error +} + +type DefaultBridge struct { + store *marketplace.Store + registry *marketregistry.Client + localCatalog *marketplace.LocalCatalog + autoInstallSkill bool + afterInstall func(context.Context, *marketplace.InstallReceipt) error + afterBind func(context.Context, *marketplace.Binding) error + beforeUninstall func(context.Context, *marketplace.InstallReceipt) error + afterUninstall func(context.Context, *marketplace.UninstallResult) error +} + +func New(opts Options) *DefaultBridge { + return &DefaultBridge{ + store: opts.Store, + registry: opts.Registry, + localCatalog: opts.LocalCatalog, + autoInstallSkill: opts.AutoInstallSkill, + afterInstall: opts.AfterInstall, + afterBind: opts.AfterBind, + beforeUninstall: opts.BeforeUninstall, + afterUninstall: opts.AfterUninstall, + } +} + +func (b *DefaultBridge) Search(ctx context.Context, req SearchRequest) (SearchResult, error) { + if b == nil || b.store == nil { + return SearchResult{}, fmt.Errorf("marketplace bridge store is not configured") + } + limit := req.Limit + if limit <= 0 { + limit = 5 + } + local, err := localArtifacts(b.store, req.Kind, limit) + if err != nil { + return SearchResult{}, err + } + var cloud []marketplace.Artifact + var cloudErr string + if req.Source != marketplace.SourceLocal && b.registry != nil { + result, err := b.registry.List(ctx, marketplace.Filter{Kind: req.Kind, Query: req.Query, Limit: limit}) + if err != nil { + cloudErr = err.Error() + } else { + cloud = result.Items + } + } + if req.Source == marketplace.SourceCloud { + local = nil + } + return SearchResult{Local: local, Cloud: cloud, CloudErr: cloudErr}, nil +} + +func (b *DefaultBridge) List(ctx context.Context, filter marketplace.Filter) (ListResult, error) { + if filter.Source == marketplace.SourceCloud { + result, cloudErr := b.listCloud(ctx, filter) + return ListResult{Result: result, CloudErr: cloudErr}, nil + } + if b == nil || b.localCatalog == nil { + return ListResult{}, fmt.Errorf("marketplace local catalog is not configured") + } + result, err := b.localCatalog.List(ctx, filter) + if err != nil { + return ListResult{}, err + } + result.Items = b.overlayStatus(result.Items) + return ListResult{Result: result}, nil +} + +func (b *DefaultBridge) Get(ctx context.Context, artifactID string, source marketplace.SourceKind) (*marketplace.Artifact, error) { + artifactID = strings.TrimSpace(artifactID) + if artifactID == "" { + return nil, marketplace.ErrArtifactNotFound + } + if source == marketplace.SourceCloud { + if b == nil || b.registry == nil { + return nil, marketregistry.ErrNotConfigured + } + artifact, err := b.registry.Get(ctx, artifactID) + if err != nil { + return nil, err + } + items := b.overlayStatus([]marketplace.Artifact{*artifact}) + if len(items) > 0 { + artifact = &items[0] + } + return artifact, nil + } + if b == nil || b.localCatalog == nil { + return nil, fmt.Errorf("marketplace local catalog is not configured") + } + artifact, err := b.localCatalog.Get(ctx, artifactID) + if err != nil { + return nil, err + } + items := b.overlayStatus([]marketplace.Artifact{*artifact}) + if len(items) > 0 { + artifact = &items[0] + } + return artifact, nil +} + +func (b *DefaultBridge) Versions(ctx context.Context, artifactID string, source marketplace.SourceKind) ([]marketplace.ArtifactVersion, error) { + artifactID = strings.TrimSpace(artifactID) + if artifactID == "" { + return nil, marketplace.ErrArtifactNotFound + } + if source == marketplace.SourceCloud { + if b == nil || b.registry == nil { + return nil, marketregistry.ErrNotConfigured + } + return b.registry.Versions(ctx, artifactID) + } + if b == nil || b.localCatalog == nil { + return nil, fmt.Errorf("marketplace local catalog is not configured") + } + return b.localCatalog.Versions(ctx, artifactID) +} + +func (b *DefaultBridge) Resolve(ctx context.Context, artifactID, versionConstraint string) (marketplace.ResolvedPackage, error) { + if b == nil || b.registry == nil { + return marketplace.ResolvedPackage{}, marketregistry.ErrNotConfigured + } + resolved, err := b.registry.Resolve(ctx, strings.TrimSpace(artifactID), marketregistry.ResolveRequest{VersionConstraint: strings.TrimSpace(versionConstraint)}) + if err != nil { + return marketplace.ResolvedPackage{}, err + } + return resolvedPackage(resolved), nil +} + +func (b *DefaultBridge) PlanInstall(ctx context.Context, req marketplace.InstallRequest) (InstallPlan, error) { + req.ArtifactID = strings.TrimSpace(req.ArtifactID) + req.VersionConstraint = strings.TrimSpace(req.VersionConstraint) + if req.ArtifactID == "" { + return InstallPlan{}, fmt.Errorf("artifact_id is required") + } + resolved, err := b.Resolve(ctx, req.ArtifactID, req.VersionConstraint) + if err != nil { + return InstallPlan{}, err + } + decision := b.decideInstall(req, resolved) + return InstallPlan{Request: req, Artifact: resolved, Decision: decision}, nil +} + +func (b *DefaultBridge) Install(ctx context.Context, req marketplace.InstallRequest) (InstallResult, error) { + result, err := b.StartInstall(ctx, req) + if err != nil { + return result, err + } + if result.Reused || result.Job == nil { + return result, nil + } + latest, err := b.ExecuteJob(ctx, result.Job.ID) + if err != nil { + return InstallResult{Job: latest, Reused: result.Reused}, err + } + return InstallResult{Job: latest, Reused: result.Reused}, nil +} + +func (b *DefaultBridge) StartInstall(ctx context.Context, req marketplace.InstallRequest) (InstallResult, error) { + if b == nil || b.store == nil || b.registry == nil { + return InstallResult{}, fmt.Errorf("marketplace install is not configured") + } + uc := marketplace.NewInstallUseCaseWithPolicy(b.store, registryAdapter{client: b.registry}, marketplace.PolicyConfig{AutoInstallSkill: b.autoInstallSkill}) + job, reused, err := uc.Start(ctx, req) + if err != nil { + return InstallResult{}, err + } + return InstallResult{Job: job, Reused: reused}, nil +} + +func (b *DefaultBridge) StartUpgrade(ctx context.Context, req marketplace.UpgradeRequest) (InstallResult, error) { + if b == nil || b.store == nil || b.registry == nil { + return InstallResult{}, fmt.Errorf("marketplace upgrade is not configured") + } + job, reused, err := b.store.CreateUpgradeJob(req, req.IdempotencyKey) + if err != nil { + return InstallResult{}, err + } + if !reused { + _ = b.store.AppendAudit(marketplace.MarketAuditEvent{ + Type: "market.upgrade.started", + ArtifactID: job.ArtifactID, + JobID: job.ID, + Actor: firstNonEmpty(req.InstalledBy, "user"), + Detail: map[string]any{ + "version_constraint": req.VersionConstraint, + "previous_version": job.Metadata["previous_version"], + }, + }) + _ = b.store.AppendEvent(marketplace.MarketEvent{ + Type: "market.upgrade.started", + Level: "info", + Message: "Marketplace upgrade started", + ArtifactID: job.ArtifactID, + JobID: job.ID, + Payload: map[string]any{ + "version_constraint": req.VersionConstraint, + "previous_version": job.Metadata["previous_version"], + }, + }) + } + return InstallResult{Job: job, Reused: reused}, nil +} + +func (b *DefaultBridge) ExecuteJob(ctx context.Context, jobID string) (*marketplace.InstallJob, error) { + if b == nil || b.store == nil || b.registry == nil { + return nil, fmt.Errorf("marketplace install is not configured") + } + uc := marketplace.NewInstallUseCaseWithPolicy(b.store, registryAdapter{client: b.registry}, marketplace.PolicyConfig{AutoInstallSkill: b.autoInstallSkill}) + if err := uc.Execute(ctx, strings.TrimSpace(jobID)); err != nil { + latest, _ := b.store.GetJob(strings.TrimSpace(jobID)) + return latest, err + } + latest, err := b.store.GetJob(strings.TrimSpace(jobID)) + if err != nil { + return nil, err + } + if err := b.afterSuccessfulInstall(ctx, latest); err != nil { + return latest, err + } + return latest, nil +} + +func (b *DefaultBridge) ListJobs(limit int) (marketplace.JobListResult, error) { + if b == nil || b.store == nil { + return marketplace.JobListResult{}, fmt.Errorf("marketplace bridge store is not configured") + } + return b.store.ListJobs(limit) +} + +func (b *DefaultBridge) GetJob(jobID string) (*marketplace.InstallJob, error) { + if b == nil || b.store == nil { + return nil, fmt.Errorf("marketplace bridge store is not configured") + } + return b.store.GetJob(strings.TrimSpace(jobID)) +} + +func (b *DefaultBridge) Bind(ctx context.Context, req marketplace.BindingRequest) (*marketplace.Binding, error) { + if b == nil || b.store == nil { + return nil, fmt.Errorf("marketplace bridge store is not configured") + } + binding, err := b.store.CreateBinding(req) + if err != nil { + return nil, err + } + if b.afterBind != nil { + if err := b.afterBind(ctx, binding); err != nil { + return binding, err + } + } + _ = b.store.AppendAudit(marketplace.MarketAuditEvent{ + Type: "market.agent_bind.succeeded", + ArtifactID: binding.ArtifactID, + BindingID: binding.ID, + Actor: "agent", + Detail: map[string]any{ + "target_type": binding.TargetType, + "target_id": binding.TargetID, + "version": binding.Version, + }, + }) + return binding, nil +} + +func (b *DefaultBridge) ListBindings() (marketplace.BindingListResult, error) { + if b == nil || b.store == nil { + return marketplace.BindingListResult{}, fmt.Errorf("marketplace bridge store is not configured") + } + return b.store.ListBindings() +} + +func (b *DefaultBridge) DeleteBinding(ctx context.Context, bindingID string) (*marketplace.Binding, error) { + if b == nil || b.store == nil { + return nil, fmt.Errorf("marketplace bridge store is not configured") + } + binding := b.findBinding(bindingID) + if err := b.store.DeleteBinding(strings.TrimSpace(bindingID)); err != nil { + return nil, err + } + if binding != nil && b.afterBind != nil { + if err := b.afterBind(ctx, binding); err != nil { + return binding, err + } + } + _ = b.store.AppendAudit(marketplace.MarketAuditEvent{ + Type: "market.binding.deleted", + BindingID: strings.TrimSpace(bindingID), + Actor: "user", + }) + _ = b.store.AppendEvent(marketplace.MarketEvent{ + Type: "market.binding.deleted", + Level: "info", + Message: "Marketplace binding deleted", + BindingID: strings.TrimSpace(bindingID), + }) + return binding, nil +} + +func (b *DefaultBridge) Uninstall(ctx context.Context, req marketplace.UninstallRequest) (*marketplace.UninstallResult, error) { + if b == nil || b.store == nil { + return nil, fmt.Errorf("marketplace bridge store is not configured") + } + receipt, err := b.uninstallReceipt(req) + if err != nil { + return nil, err + } + if b.beforeUninstall != nil { + if err := b.beforeUninstall(ctx, receipt); err != nil { + return nil, err + } + } + result, err := marketplace.NewLifecycleService(b.store).Uninstall(req) + if err != nil { + return nil, err + } + if b.afterUninstall != nil { + if err := b.afterUninstall(ctx, result); err != nil { + return result, err + } + } + return result, nil +} + +func (b *DefaultBridge) ListEvents(limit int) (marketplace.MarketEventListResult, error) { + if b == nil || b.store == nil { + return marketplace.MarketEventListResult{}, fmt.Errorf("marketplace bridge store is not configured") + } + return b.store.ListEvents(limit) +} + +func (b *DefaultBridge) decideInstall(req marketplace.InstallRequest, resolved marketplace.ResolvedPackage) marketplace.PolicyDecision { + autoInstall := false + if b != nil { + autoInstall = b.autoInstallSkill + } + return marketplace.NewDecisionPolicy(marketplace.PolicyConfig{AutoInstallSkill: autoInstall}).DecideInstall(req, resolved) +} + +func (b *DefaultBridge) afterSuccessfulInstall(ctx context.Context, job *marketplace.InstallJob) error { + if b == nil || b.store == nil || b.afterInstall == nil || job == nil || job.State != marketplace.JobSucceeded || strings.TrimSpace(job.ReceiptID) == "" { + return nil + } + receipt, err := b.store.GetReceipt(job.ReceiptID) + if err != nil { + return err + } + return b.afterInstall(ctx, receipt) +} + +func (b *DefaultBridge) findBinding(bindingID string) *marketplace.Binding { + if b == nil || b.store == nil || strings.TrimSpace(bindingID) == "" { + return nil + } + result, err := b.store.ListBindings() + if err != nil { + return nil + } + for i := range result.Items { + if strings.EqualFold(strings.TrimSpace(result.Items[i].ID), strings.TrimSpace(bindingID)) { + return &result.Items[i] + } + } + return nil +} + +func (b *DefaultBridge) uninstallReceipt(req marketplace.UninstallRequest) (*marketplace.InstallReceipt, error) { + if b == nil || b.store == nil { + return nil, fmt.Errorf("marketplace bridge store is not configured") + } + if receiptID := strings.TrimSpace(req.ReceiptID); receiptID != "" { + return b.store.GetReceipt(receiptID) + } + return b.store.LatestReceiptForArtifact(strings.TrimSpace(req.ArtifactID)) +} + +func (b *DefaultBridge) listCloud(ctx context.Context, filter marketplace.Filter) (marketplace.ListResult, string) { + if b == nil || b.registry == nil { + return emptyList(filter), "cloud registry endpoint is not configured" + } + cloudFilter := filter + cloudFilter.Source = "" + result, err := b.registry.List(ctx, cloudFilter) + if err != nil { + return emptyList(filter), err.Error() + } + result.Items = b.overlayStatus(result.Items) + applyStatusFilter(&result, filter.Status) + return result, "" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func (b *DefaultBridge) overlayStatus(items []marketplace.Artifact) []marketplace.Artifact { + if b == nil || b.store == nil || len(items) == 0 { + return items + } + return b.store.OverlayStatus(items) +} + +func localArtifacts(store *marketplace.Store, kind marketplace.ArtifactKind, limit int) ([]marketplace.Artifact, error) { + receipts, err := store.ListReceipts() + if err != nil { + return nil, err + } + items := make([]marketplace.Artifact, 0, len(receipts)) + for _, receipt := range receipts { + if kind != "" && receipt.Kind != kind { + continue + } + items = append(items, marketplace.Artifact{ + ID: receipt.ArtifactID, + Kind: receipt.Kind, + Name: receipt.Name, + DisplayName: receipt.Name, + Version: receipt.Version, + Source: marketplace.SourceLocal, + Status: marketplace.StatusInstalled, + Installed: true, + Enabled: true, + Permissions: append([]string(nil), receipt.Permissions...), + RiskLevel: receipt.RiskLevel, + TrustLevel: receipt.TrustLevel, + Compatibility: receipt.Compatibility, + Dependencies: append([]marketplace.ArtifactDependency(nil), receipt.Dependencies...), + Capabilities: []string{receipt.Name, string(receipt.Kind)}, + }) + if limit > 0 && len(items) >= limit { + break + } + } + return items, nil +} + +func emptyList(filter marketplace.Filter) marketplace.ListResult { + limit := filter.Limit + if limit <= 0 { + limit = 50 + } + offset := filter.Offset + if offset < 0 { + offset = 0 + } + return marketplace.ListResult{Items: []marketplace.Artifact{}, Total: 0, Limit: limit, Offset: offset} +} + +func applyStatusFilter(result *marketplace.ListResult, status marketplace.ArtifactStatus) { + if result == nil || status == "" { + return + } + items := result.Items[:0] + for _, item := range result.Items { + if item.Status == status { + items = append(items, item) + } + } + result.Items = items + result.Total = len(items) +} + +type registryAdapter struct { + client *marketregistry.Client +} + +func (a registryAdapter) Resolve(ctx context.Context, artifactID, versionConstraint string) (marketplace.ResolvedPackage, error) { + if a.client == nil { + return marketplace.ResolvedPackage{}, marketregistry.ErrNotConfigured + } + resolved, err := a.client.Resolve(ctx, artifactID, marketregistry.ResolveRequest{VersionConstraint: versionConstraint}) + if err != nil { + return marketplace.ResolvedPackage{}, err + } + return resolvedPackage(resolved), nil +} + +func (a registryAdapter) Download(ctx context.Context, rawURL string) ([]byte, error) { + if a.client == nil { + return nil, marketregistry.ErrNotConfigured + } + return a.client.Download(ctx, rawURL) +} + +func resolvedPackage(resolved marketregistry.ResolvedArtifact) marketplace.ResolvedPackage { + return marketplace.ResolvedPackage{ + ArtifactID: resolved.ArtifactID, + Version: resolved.Version, + DownloadURL: resolved.DownloadURL, + ChecksumSHA256: resolved.ChecksumSHA256, + SizeBytes: resolved.SizeBytes, + Compatibility: resolved.Compatibility, + Dependencies: resolved.Dependencies, + RiskLevel: resolved.RiskLevel, + TrustLevel: resolved.TrustLevel, + Permissions: append([]string(nil), resolved.Permissions...), + Signature: resolved.Signature, + Kind: resolved.Kind, + Name: resolved.Name, + } +} diff --git a/pkg/marketplace/bridge/bridge_test.go b/pkg/marketplace/bridge/bridge_test.go new file mode 100644 index 00000000..f4532907 --- /dev/null +++ b/pkg/marketplace/bridge/bridge_test.go @@ -0,0 +1,358 @@ +package bridge + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/1024XEngineer/anyclaw/pkg/config" + "github.com/1024XEngineer/anyclaw/pkg/marketplace" + marketregistry "github.com/1024XEngineer/anyclaw/pkg/marketplace/registry" +) + +func TestBridgeSearchInstallAndBind(t *testing.T) { + archive := bridgeArchive(t, "cloud.skill.release-notes", marketplace.ArtifactKindSkill, "1.0.0") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/v1/artifacts": + writeBridgeJSON(t, w, map[string]any{"data": map[string]any{ + "items": []map[string]any{{ + "id": "cloud.skill.release-notes", + "kind": "skill", + "name": "Release Notes", + "summary": "Writes notes.", + "latest_version": "1.0.0", + "source": "anyclaw-cloud", + }}, + "total": 1, + }}) + case strings.HasSuffix(r.URL.Path, "/resolve"): + writeBridgeJSON(t, w, map[string]any{"data": map[string]any{ + "artifact_id": "cloud.skill.release-notes", + "version": "1.0.0", + "download_url": "http://" + r.Host + "/v1/download/cloud.skill.release-notes/1.0.0", + "checksum_sha256": bridgeSHA256(archive), + "size_bytes": len(archive), + "risk_level": "low", + "trust_level": "verified", + "permissions": []string{"fs.read"}, + "kind": "skill", + "name": "Release Notes", + }}) + case strings.Contains(r.URL.Path, "/v1/download/"): + _, _ = w.Write(archive) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + store := marketplace.NewStore(t.TempDir()) + b := New(Options{ + Store: store, + Registry: marketregistry.NewClient(marketregistry.ClientConfig{Endpoint: server.URL}), + AutoInstallSkill: true, + }) + search, err := b.Search(context.Background(), SearchRequest{Query: "release", Kind: marketplace.ArtifactKindSkill, Limit: 5}) + if err != nil { + t.Fatal(err) + } + if len(search.Cloud) != 1 || search.Cloud[0].ID != "cloud.skill.release-notes" { + t.Fatalf("unexpected cloud search: %#v", search.Cloud) + } + install, err := b.Install(context.Background(), marketplace.InstallRequest{ArtifactID: "cloud.skill.release-notes", InstalledBy: "agent", UserConfirmed: true}) + if err != nil { + t.Fatal(err) + } + if install.Job == nil || install.Job.State != marketplace.JobSucceeded { + t.Fatalf("unexpected install result: %#v", install) + } + binding, err := b.Bind(context.Background(), marketplace.BindingRequest{ArtifactID: "cloud.skill.release-notes", TargetType: marketplace.TargetRuntimeGlobal}) + if err != nil { + t.Fatal(err) + } + if binding.ArtifactID != "cloud.skill.release-notes" { + t.Fatalf("unexpected binding: %#v", binding) + } +} + +func TestBridgeCatalogCloudStatusAndInstallPlanning(t *testing.T) { + archive := bridgeArchive(t, "cloud.skill.release-notes", marketplace.ArtifactKindSkill, "1.0.0") + server := bridgeRegistryServer(t, archive) + defer server.Close() + + store := marketplace.NewStore(t.TempDir()) + if err := store.SaveReceipt(&marketplace.InstallReceipt{ + ID: "cloud.skill.release-notes@0.9.0", + ArtifactID: "cloud.skill.release-notes", + Kind: marketplace.ArtifactKindSkill, + Name: "Release Notes", + Version: "0.9.0", + Source: marketplace.SourceCloud, + InstalledPath: filepath.Join(t.TempDir(), "installed"), + InstalledBy: "user", + InstalledAt: time.Now().UTC().Format(time.RFC3339), + }); err != nil { + t.Fatal(err) + } + cfg := config.DefaultConfig() + cfg.Agent.Name = "Main" + localCatalog := marketplace.NewLocalCatalog(marketplace.LocalCatalogDeps{Config: cfg}) + b := New(Options{ + Store: store, + Registry: marketregistry.NewClient(marketregistry.ClientConfig{Endpoint: server.URL}), + LocalCatalog: localCatalog, + AutoInstallSkill: true, + }) + + local, err := b.List(context.Background(), marketplace.Filter{Source: marketplace.SourceLocal}) + if err != nil { + t.Fatal(err) + } + if local.Result.Total == 0 || local.Result.Items[0].Source != marketplace.SourceLocal { + t.Fatalf("unexpected local list: %#v", local.Result) + } + cloud, err := b.List(context.Background(), marketplace.Filter{Source: marketplace.SourceCloud, Status: marketplace.StatusInstalled}) + if err != nil { + t.Fatal(err) + } + if cloud.CloudErr != "" || cloud.Result.Total != 1 || cloud.Result.Items[0].Status != marketplace.StatusInstalled { + t.Fatalf("unexpected cloud list overlay: %#v cloudErr=%q", cloud.Result, cloud.CloudErr) + } + artifact, err := b.Get(context.Background(), "cloud.skill.release-notes", marketplace.SourceCloud) + if err != nil { + t.Fatal(err) + } + if artifact.Status != marketplace.StatusInstalled { + t.Fatalf("expected installed overlay, got %#v", artifact) + } + versions, err := b.Versions(context.Background(), "cloud.skill.release-notes", marketplace.SourceCloud) + if err != nil { + t.Fatal(err) + } + if len(versions) != 1 || versions[0].Version != "1.0.0" { + t.Fatalf("unexpected versions: %#v", versions) + } + plan, err := b.PlanInstall(context.Background(), marketplace.InstallRequest{ArtifactID: " cloud.skill.release-notes ", UserConfirmed: true}) + if err != nil { + t.Fatal(err) + } + if plan.Request.ArtifactID != "cloud.skill.release-notes" || plan.Decision.Decision != marketplace.DecisionAuto { + t.Fatalf("unexpected install plan: %#v", plan) + } + if _, err := b.PlanInstall(context.Background(), marketplace.InstallRequest{}); err == nil { + t.Fatal("expected missing artifact_id error") + } +} + +func TestBridgeJobsBindingsEventsAndUninstallHooks(t *testing.T) { + archive := bridgeArchive(t, "cloud.skill.release-notes", marketplace.ArtifactKindSkill, "1.0.0") + server := bridgeRegistryServer(t, archive) + defer server.Close() + + store := marketplace.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 := &marketplace.InstallReceipt{ + ID: "cloud.skill.release-notes@1.0.0", + ArtifactID: "cloud.skill.release-notes", + Kind: marketplace.ArtifactKindSkill, + Name: "Release Notes", + Version: "1.0.0", + Source: marketplace.SourceCloud, + InstalledPath: installedPath, + InstalledBy: "user", + InstalledAt: time.Now().UTC().Format(time.RFC3339), + } + if err := store.SaveReceipt(receipt); err != nil { + t.Fatal(err) + } + var bindHookCount int + var beforeUninstallReceipt string + var afterUninstallReceipt string + b := New(Options{ + Store: store, + Registry: marketregistry.NewClient(marketregistry.ClientConfig{Endpoint: server.URL}), + AfterBind: func(ctx context.Context, binding *marketplace.Binding) error { + bindHookCount++ + return nil + }, + BeforeUninstall: func(ctx context.Context, receipt *marketplace.InstallReceipt) error { + beforeUninstallReceipt = receipt.ID + return nil + }, + AfterUninstall: func(ctx context.Context, result *marketplace.UninstallResult) error { + afterUninstallReceipt = result.ReceiptID + return nil + }, + }) + + upgrade, err := b.StartUpgrade(context.Background(), marketplace.UpgradeRequest{ArtifactID: receipt.ArtifactID, InstalledBy: "tester", IdempotencyKey: "upgrade-1"}) + if err != nil { + t.Fatal(err) + } + if upgrade.Job == nil || upgrade.Job.Type != "upgrade" || upgrade.Job.Metadata["previous_version"] != "1.0.0" { + t.Fatalf("unexpected upgrade job: %#v", upgrade.Job) + } + jobs, err := b.ListJobs(10) + if err != nil || jobs.Total == 0 { + t.Fatalf("jobs = %#v err=%v", jobs, err) + } + if _, err := b.GetJob(upgrade.Job.ID); err != nil { + t.Fatal(err) + } + binding, err := b.Bind(context.Background(), marketplace.BindingRequest{ArtifactID: receipt.ArtifactID, TargetType: marketplace.TargetRuntimeGlobal}) + if err != nil { + t.Fatal(err) + } + deleted, err := b.DeleteBinding(context.Background(), binding.ID) + if err != nil { + t.Fatal(err) + } + if deleted == nil || deleted.ID != binding.ID || bindHookCount != 2 { + t.Fatalf("delete binding hook mismatch deleted=%#v count=%d", deleted, bindHookCount) + } + events, err := b.ListEvents(20) + if err != nil || events.Total == 0 { + t.Fatalf("events = %#v err=%v", events, err) + } + result, err := b.Uninstall(context.Background(), marketplace.UninstallRequest{ReceiptID: receipt.ID}) + if err != nil { + t.Fatal(err) + } + if result.ReceiptID != receipt.ID || beforeUninstallReceipt != receipt.ID || afterUninstallReceipt != receipt.ID { + t.Fatalf("uninstall hooks/result mismatch result=%#v before=%q after=%q", result, beforeUninstallReceipt, afterUninstallReceipt) + } + if _, err := os.Stat(installedPath); !os.IsNotExist(err) { + t.Fatalf("installed path err=%v, want not exist", err) + } +} + +func TestBridgeConfigurationErrorsAndCloudDegrade(t *testing.T) { + if _, err := (*DefaultBridge)(nil).Search(context.Background(), SearchRequest{}); err == nil { + t.Fatal("expected nil bridge search error") + } + if _, err := New(Options{}).List(context.Background(), marketplace.Filter{}); err == nil { + t.Fatal("expected missing local catalog error") + } + if _, err := New(Options{}).Get(context.Background(), "", marketplace.SourceLocal); err != marketplace.ErrArtifactNotFound { + t.Fatalf("expected ErrArtifactNotFound, got %v", err) + } + cloud, err := New(Options{}).List(context.Background(), marketplace.Filter{Source: marketplace.SourceCloud, Limit: -1, Offset: -3}) + if err != nil { + t.Fatal(err) + } + if cloud.CloudErr == "" || cloud.Result.Limit != 50 || cloud.Result.Offset != 0 { + t.Fatalf("unexpected degraded cloud list: %#v err=%q", cloud.Result, cloud.CloudErr) + } + if _, err := New(Options{}).Resolve(context.Background(), "x", ""); err != marketregistry.ErrNotConfigured { + t.Fatalf("expected registry config error, got %v", err) + } + if _, err := New(Options{Store: marketplace.NewStore(t.TempDir())}).StartInstall(context.Background(), marketplace.InstallRequest{ArtifactID: "x"}); err == nil { + t.Fatal("expected start install config error") + } + if _, err := New(Options{Store: marketplace.NewStore(t.TempDir())}).ExecuteJob(context.Background(), "missing"); err == nil { + t.Fatal("expected execute job config error") + } +} + +func bridgeArchive(t *testing.T, id string, kind marketplace.ArtifactKind, version string) []byte { + t.Helper() + var buf bytes.Buffer + writer := zip.NewWriter(&buf) + w, err := writer.Create("anyclaw.artifact.json") + if err != nil { + t.Fatal(err) + } + data, _ := json.Marshal(map[string]any{"id": id, "kind": kind, "name": id, "version": version}) + if _, err := w.Write(data); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func bridgeRegistryServer(t *testing.T, archive []byte) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/v1/artifacts": + writeBridgeJSON(t, w, map[string]any{"data": map[string]any{ + "items": []map[string]any{{ + "id": "cloud.skill.release-notes", + "kind": "skill", + "name": "Release Notes", + "summary": "Writes notes.", + "latest_version": "1.0.0", + "source": "anyclaw-cloud", + "risk_level": "low", + "trust_level": "verified", + "permissions": []string{"fs.read"}, + }}, + "total": 1, + "limit": 5, + }}) + case r.URL.Path == "/v1/artifacts/cloud.skill.release-notes": + writeBridgeJSON(t, w, map[string]any{"data": map[string]any{ + "id": "cloud.skill.release-notes", + "kind": "skill", + "name": "Release Notes", + "summary": "Writes notes.", + "latest_version": "1.0.0", + "source": "anyclaw-cloud", + "risk_level": "low", + "trust_level": "verified", + "permissions": []string{"fs.read"}, + }}) + case r.URL.Path == "/v1/artifacts/cloud.skill.release-notes/versions": + writeBridgeJSON(t, w, map[string]any{"data": map[string]any{ + "items": []map[string]any{{"version": "1.0.0"}}, + "total": 1, + }}) + case strings.HasSuffix(r.URL.Path, "/resolve"): + writeBridgeJSON(t, w, map[string]any{"data": map[string]any{ + "artifact_id": "cloud.skill.release-notes", + "version": "1.0.0", + "download_url": "http://" + r.Host + "/v1/download/cloud.skill.release-notes/1.0.0", + "checksum_sha256": bridgeSHA256(archive), + "size_bytes": len(archive), + "risk_level": "low", + "trust_level": "verified", + "permissions": []string{"fs.read"}, + "kind": "skill", + "name": "Release Notes", + }}) + case strings.Contains(r.URL.Path, "/v1/download/"): + _, _ = w.Write(archive) + default: + http.NotFound(w, r) + } + })) +} + +func bridgeSHA256(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func writeBridgeJSON(t *testing.T, w http.ResponseWriter, payload any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/runtime/bootstrap.go b/pkg/runtime/bootstrap.go index b858c03d..003aa069 100644 --- a/pkg/runtime/bootstrap.go +++ b/pkg/runtime/bootstrap.go @@ -16,6 +16,7 @@ import ( "github.com/1024XEngineer/anyclaw/pkg/config" "github.com/1024XEngineer/anyclaw/pkg/extensions/plugin" "github.com/1024XEngineer/anyclaw/pkg/marketplace" + marketbridge "github.com/1024XEngineer/anyclaw/pkg/marketplace/bridge" marketregistry "github.com/1024XEngineer/anyclaw/pkg/marketplace/registry" "github.com/1024XEngineer/anyclaw/pkg/qmd" "github.com/1024XEngineer/anyclaw/pkg/runtime/orchestrator" @@ -343,12 +344,8 @@ func Bootstrap(opts BootstrapOptions) (*MainRuntime, error) { } tools.RegisterBuiltins(registry, builtinOpts) markettools.Register(registry, markettools.Options{ - Store: marketplace.NewStore(marketplaceStoreRoot(workDir, workingDir)), - Registry: marketplaceRegistryClient(app.Config.Marketplace), - AutoInstallSkill: app.Config.Marketplace.AutoInstallSkill, - AuditLogger: auditLogger, - AfterInstall: app.IntegrateMarketReceiptAndRefresh, - AfterBind: app.RefreshAfterMarketBinding, + Bridge: app.marketplaceBridge(workDir, workingDir), + AuditLogger: auditLogger, }) sk.RegisterTools(registry, skills.ExecutionOptions{AllowExec: app.Config.Plugins.AllowExec, ExecTimeoutSeconds: app.Config.Plugins.ExecTimeoutSeconds}) app.Tools = registry @@ -464,6 +461,20 @@ func marketplaceRegistryClient(cfg config.MarketplaceConfig) *marketregistry.Cli return marketregistry.NewClientFromConfig(cfg) } +func (a *MainRuntime) marketplaceBridge(workDir, workingDir string) marketbridge.Bridge { + if a == nil || a.Config == nil { + return nil + } + return marketbridge.New(marketbridge.Options{ + Store: marketplace.NewStore(marketplaceStoreRoot(workDir, workingDir)), + Registry: marketplaceRegistryClient(a.Config.Marketplace), + AutoInstallSkill: a.Config.Marketplace.AutoInstallSkill, + AfterInstall: a.IntegrateMarketReceiptAndRefresh, + AfterBind: a.RefreshAfterMarketBinding, + BeforeUninstall: a.CleanupMarketReceiptAndRefresh, + }) +} + func bootstrapUserProfile(cfg *config.Config) string { if cfg == nil { return "" diff --git a/pkg/runtime/market_integration.go b/pkg/runtime/market_integration.go index 72aafa64..80e73b42 100644 --- a/pkg/runtime/market_integration.go +++ b/pkg/runtime/market_integration.go @@ -54,6 +54,33 @@ func (a *MainRuntime) IntegrateMarketReceiptAndRefresh(ctx context.Context, rece return a.RefreshToolRegistry() } +func (a *MainRuntime) CleanupMarketReceiptAndRefresh(ctx context.Context, receipt *marketplace.InstallReceipt) error { + if a == nil || a.Config == nil { + return fmt.Errorf("runtime config is unavailable") + } + if receipt == nil { + return fmt.Errorf("install receipt is nil") + } + manifest := readRuntimeMarketArtifactManifest(receipt.InstalledPath) + switch receipt.Kind { + case marketplace.ArtifactKindSkill: + if err := a.cleanupRuntimeMarketSkill(receipt, manifest); err != nil { + return err + } + case marketplace.ArtifactKindAgent: + if err := a.cleanupRuntimeMarketAgent(receipt, manifest); err != nil { + return err + } + case marketplace.ArtifactKindCLI: + if err := a.cleanupRuntimeMarketCLI(receipt, manifest); err != nil { + return err + } + default: + return nil + } + return a.RefreshToolRegistry() +} + func (a *MainRuntime) RefreshAfterMarketBinding(ctx context.Context, binding *marketplace.Binding) error { if a == nil { return fmt.Errorf("runtime is unavailable") @@ -78,6 +105,38 @@ func (a *MainRuntime) RefreshAfterMarketBinding(ctx context.Context, binding *ma return nil } +func (a *MainRuntime) cleanupRuntimeMarketSkill(receipt *marketplace.InstallReceipt, manifest runtimeMarketArtifactManifest) error { + skillName := firstNonEmptyRuntimeMarket(manifest.Name, receipt.Name, receipt.ArtifactID) + skillDirName := safeRuntimeMarketName(firstNonEmptyRuntimeMarket(receipt.ArtifactID, skillName)) + skillsDir := config.ResolvePath(a.ConfigPath, a.Config.Skills.Dir) + if skillsDir == "" { + skillsDir = config.ResolvePath(a.ConfigPath, "skills") + } + targetDir := filepath.Join(skillsDir, skillDirName) + if err := removeRuntimeMarketPathWithin(skillsDir, targetDir); err != nil { + return err + } + return a.detachRuntimeMarketSkill(skillName) +} + +func (a *MainRuntime) cleanupRuntimeMarketAgent(receipt *marketplace.InstallReceipt, manifest runtimeMarketArtifactManifest) error { + agentName := firstNonEmptyRuntimeMarket(manifest.Name, receipt.Name, receipt.ArtifactID) + if strings.TrimSpace(agentName) == "" || !a.Config.DeleteAgentProfile(agentName) { + return nil + } + return a.Config.Save(a.ConfigPath) +} + +func (a *MainRuntime) cleanupRuntimeMarketCLI(receipt *marketplace.InstallReceipt, manifest runtimeMarketArtifactManifest) error { + root := filepath.Join(a.WorkingDir, "CLI-Anything") + spec, err := runtimeMarketCLISpec(receipt.InstalledPath, receipt, manifest) + if err != nil { + spec.Name = firstNonEmptyRuntimeMarket(manifest.ManifestSummary["command"], manifest.Name, receipt.Name, receipt.ArtifactID) + } + entryName := safeRuntimeMarketName(firstNonEmptyRuntimeMarket(spec.Name, manifest.ManifestSummary["command"], manifest.Name, receipt.Name, receipt.ArtifactID)) + return removeRuntimeMarketCLIRegistryEntry(filepath.Join(root, "registry.json"), entryName) +} + func (a *MainRuntime) integrateRuntimeMarketSkill(receipt *marketplace.InstallReceipt, manifest runtimeMarketArtifactManifest) error { skillName := firstNonEmptyRuntimeMarket(manifest.Name, receipt.Name, receipt.ArtifactID) skillDirName := safeRuntimeMarketName(firstNonEmptyRuntimeMarket(receipt.ArtifactID, skillName)) @@ -235,6 +294,45 @@ func (a *MainRuntime) attachRuntimeMarketSkill(name, version string, permissions return a.Config.Save(a.ConfigPath) } +func (a *MainRuntime) detachRuntimeMarketSkill(name string) error { + name = strings.TrimSpace(name) + if name == "" { + return nil + } + changed := false + if profile, ok := a.Config.ResolveMainAgentProfile(); ok { + filtered := profile.Skills[:0] + for _, skill := range profile.Skills { + if strings.EqualFold(strings.TrimSpace(skill.Name), name) { + changed = true + continue + } + filtered = append(filtered, skill) + } + profile.Skills = filtered + if changed { + if err := a.Config.UpsertAgentProfile(profile); err != nil { + return err + } + return a.Config.Save(a.ConfigPath) + } + return nil + } + filtered := a.Config.Agent.Skills[:0] + for _, skill := range a.Config.Agent.Skills { + if strings.EqualFold(strings.TrimSpace(skill.Name), name) { + changed = true + continue + } + filtered = append(filtered, skill) + } + a.Config.Agent.Skills = filtered + if changed { + return a.Config.Save(a.ConfigPath) + } + return nil +} + func readRuntimeMarketArtifactManifest(root string) runtimeMarketArtifactManifest { var manifest runtimeMarketArtifactManifest data, err := os.ReadFile(filepath.Join(root, "anyclaw.artifact.json")) @@ -318,6 +416,55 @@ func upsertRuntimeMarketCLIRegistryEntry(path, name string, entry map[string]any return os.WriteFile(path, data, 0o644) } +func removeRuntimeMarketCLIRegistryEntry(path, name string) error { + var registry struct { + Meta map[string]string `json:"meta"` + CLIs []map[string]any `json:"clis"` + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if err := json.Unmarshal(data, ®istry); err != nil { + return err + } + filtered := registry.CLIs[:0] + removed := false + for _, entry := range registry.CLIs { + if strings.EqualFold(fmt.Sprint(entry["name"]), name) { + removed = true + continue + } + filtered = append(filtered, entry) + } + if !removed { + return nil + } + registry.CLIs = filtered + data, err = json.MarshalIndent(registry, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(path, data, 0o644) +} + +func removeRuntimeMarketPathWithin(base, target string) error { + if strings.TrimSpace(base) == "" || strings.TrimSpace(target) == "" { + return nil + } + if !pathWithinRuntimeMarketBase(base, target) { + return fmt.Errorf("marketplace cleanup path escapes base: %s", target) + } + if err := os.RemoveAll(target); err != nil { + return err + } + return nil +} + func copyRuntimeMarketDirContents(srcDir, destDir string) error { srcDir = filepath.Clean(srcDir) destDir = filepath.Clean(destDir) diff --git a/pkg/runtime/tools_refresh.go b/pkg/runtime/tools_refresh.go index 275082fd..52f5667c 100644 --- a/pkg/runtime/tools_refresh.go +++ b/pkg/runtime/tools_refresh.go @@ -8,7 +8,6 @@ import ( "github.com/1024XEngineer/anyclaw/pkg/capability/markettools" "github.com/1024XEngineer/anyclaw/pkg/capability/skills" "github.com/1024XEngineer/anyclaw/pkg/capability/tools" - "github.com/1024XEngineer/anyclaw/pkg/marketplace" "github.com/1024XEngineer/anyclaw/pkg/state/memory" ) @@ -104,12 +103,8 @@ func (a *MainRuntime) RefreshToolRegistry() error { } tools.RegisterBuiltins(registry, builtinOpts) markettools.Register(registry, markettools.Options{ - Store: marketplace.NewStore(marketplaceStoreRoot(a.WorkDir, workingDir)), - Registry: marketplaceRegistryClient(a.Config.Marketplace), - AutoInstallSkill: a.Config.Marketplace.AutoInstallSkill, - AuditLogger: auditLogger, - AfterInstall: a.IntegrateMarketReceiptAndRefresh, - AfterBind: a.RefreshAfterMarketBinding, + Bridge: a.marketplaceBridge(a.WorkDir, workingDir), + AuditLogger: auditLogger, }) if a.Skills != nil { a.Skills.RegisterTools(registry, skills.ExecutionOptions{AllowExec: a.Config.Plugins.AllowExec, ExecTimeoutSeconds: a.Config.Plugins.ExecTimeoutSeconds}) diff --git a/pkg/runtime/tools_refresh_test.go b/pkg/runtime/tools_refresh_test.go index d457eda9..18bff493 100644 --- a/pkg/runtime/tools_refresh_test.go +++ b/pkg/runtime/tools_refresh_test.go @@ -298,6 +298,149 @@ func TestIntegrateMarketCLIRejectsMissingEntryPoint(t *testing.T) { } } +func TestCleanupMarketReceiptRemovesIntegratedSkill(t *testing.T) { + tempDir := t.TempDir() + installed := filepath.Join(tempDir, "installed") + if err := os.MkdirAll(filepath.Join(installed, "skill"), 0o755); err != nil { + t.Fatal(err) + } + writeJSONFileRuntimeMarket(t, filepath.Join(installed, "anyclaw.artifact.json"), map[string]any{ + "id": "cloud.skill.release-notes", + "kind": "skill", + "name": "Release Notes", + "version": "1.0.0", + }) + if err := os.WriteFile(filepath.Join(installed, "skill", "SKILL.md"), []byte("# Release Notes\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg := config.DefaultConfig() + cfg.Agent.Name = "Main Agent" + cfg.Agent.ActiveProfile = "Main Agent" + cfg.Agent.Profiles = []config.AgentProfile{{Name: "Main Agent"}} + cfg.Skills.Dir = filepath.Join(tempDir, "skills") + rt := &MainRuntime{ + ConfigPath: filepath.Join(tempDir, "anyclaw.json"), + Config: cfg, + WorkingDir: tempDir, + } + receipt := &marketplace.InstallReceipt{ + ID: "cloud.skill.release-notes@1.0.0", + ArtifactID: "cloud.skill.release-notes", + Kind: marketplace.ArtifactKindSkill, + Name: "Release Notes", + Version: "1.0.0", + InstalledPath: installed, + } + if err := rt.IntegrateMarketReceiptAndRefresh(context.Background(), receipt); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(cfg.Skills.Dir, "cloud-skill-release-notes")); err != nil { + t.Fatalf("expected integrated skill dir: %v", err) + } + profile, ok := cfg.ResolveMainAgentProfile() + if !ok || len(profile.Skills) != 1 || profile.Skills[0].Name != "Release Notes" { + t.Fatalf("profile skills after integrate = %#v", profile.Skills) + } + if err := rt.CleanupMarketReceiptAndRefresh(context.Background(), receipt); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(cfg.Skills.Dir, "cloud-skill-release-notes")); !os.IsNotExist(err) { + t.Fatalf("integrated skill dir err = %v, want not exist", err) + } + profile, ok = cfg.ResolveMainAgentProfile() + if !ok { + t.Fatal("expected main profile to remain") + } + if len(profile.Skills) != 0 { + t.Fatalf("profile skills after cleanup = %#v, want empty", profile.Skills) + } +} + +func TestCleanupMarketReceiptRemovesAgentProfileAndCLIEntry(t *testing.T) { + tempDir := t.TempDir() + agentInstalled := filepath.Join(tempDir, "agent-installed") + writeJSONFileRuntimeMarket(t, filepath.Join(agentInstalled, "anyclaw.artifact.json"), map[string]any{ + "id": "cloud.agent.code-reviewer", + "kind": "agent", + "name": "Code Reviewer", + "version": "1.0.0", + "description": "Reviews code.", + }) + cfg := config.DefaultConfig() + cfg.Agent.Profiles = []config.AgentProfile{{Name: "Main Agent"}, {Name: "Code Reviewer", Role: "marketplace"}} + rt := &MainRuntime{ + ConfigPath: filepath.Join(tempDir, "anyclaw.json"), + Config: cfg, + WorkingDir: filepath.Join(tempDir, "workspace"), + } + if err := rt.CleanupMarketReceiptAndRefresh(context.Background(), &marketplace.InstallReceipt{ + ID: "cloud.agent.code-reviewer@1.0.0", + ArtifactID: "cloud.agent.code-reviewer", + Kind: marketplace.ArtifactKindAgent, + Name: "Code Reviewer", + Version: "1.0.0", + InstalledPath: agentInstalled, + }); err != nil { + t.Fatal(err) + } + if _, ok := cfg.FindAgentProfile("Code Reviewer"); ok { + t.Fatal("expected marketplace agent profile to be removed") + } + + cliInstalled := filepath.Join(tempDir, "cli-installed") + if err := os.MkdirAll(filepath.Join(cliInstalled, "cli", "bin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cliInstalled, "cli", "bin", "repo-health.cmd"), []byte("@echo off\r\n"), 0o755); err != nil { + t.Fatal(err) + } + writeJSONFileRuntimeMarket(t, filepath.Join(cliInstalled, "anyclaw.artifact.json"), map[string]any{ + "id": "cloud.cli.repo-health", + "kind": "cli", + "name": "Repo Health", + "version": "1.0.0", + }) + writeJSONFileRuntimeMarket(t, filepath.Join(cliInstalled, "cli", "command.json"), map[string]any{ + "name": "repo-health", + "entry_point": "cli/bin/repo-health.cmd", + }) + if err := rt.IntegrateMarketReceiptAndRefresh(context.Background(), &marketplace.InstallReceipt{ + ID: "cloud.cli.repo-health@1.0.0", + ArtifactID: "cloud.cli.repo-health", + Kind: marketplace.ArtifactKindCLI, + Name: "Repo Health", + Version: "1.0.0", + InstalledPath: cliInstalled, + }); err != nil { + t.Fatal(err) + } + registryPath := filepath.Join(rt.WorkingDir, "CLI-Anything", "registry.json") + data, err := os.ReadFile(registryPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "repo-health") { + t.Fatalf("expected CLI registry entry, got %s", data) + } + if err := rt.CleanupMarketReceiptAndRefresh(context.Background(), &marketplace.InstallReceipt{ + ID: "cloud.cli.repo-health@1.0.0", + ArtifactID: "cloud.cli.repo-health", + Kind: marketplace.ArtifactKindCLI, + Name: "Repo Health", + Version: "1.0.0", + InstalledPath: cliInstalled, + }); err != nil { + t.Fatal(err) + } + data, err = os.ReadFile(registryPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "repo-health") { + t.Fatalf("expected CLI registry entry to be removed, got %s", data) + } +} + type refreshToolLLM struct { toolName string calls int