Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/api/core/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,8 @@ func StatusForSkillMarketplaceError(err error) int {
return http.StatusNotFound
case errors.Is(err, skillmarketplace.ErrNotMarketplace):
return http.StatusUnprocessableEntity
case errors.Is(err, skillmarketplace.ErrUnavailable):
return http.StatusServiceUnavailable
case errors.Is(err, skillmarketplace.ErrNotConfigured):
return http.StatusServiceUnavailable
default:
Expand Down
19 changes: 19 additions & 0 deletions internal/api/core/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,11 @@ func (h *BaseHandlers) InstallSkillMarketplace(c *gin.Context) {
h.respondError(c, http.StatusInternalServerError, err)
return
}
if err := skillmarketplace.VerifyInstallVisible(h.SkillsRegistry, result); err != nil {
h.logSkillMarketplaceInstallVerificationFailure(result, err)
h.respondError(c, StatusForSkillMarketplaceError(err), err)
return
}

c.JSON(http.StatusOK, contract.SkillMarketplaceInstallResponse{
Skill: SkillMarketplaceInstallPayloadFromResult(result),
Expand Down Expand Up @@ -491,3 +496,17 @@ func (h *BaseHandlers) refreshSkillsAfterMarketplaceMutation(c *gin.Context) err
}
return nil
}

func (h *BaseHandlers) logSkillMarketplaceInstallVerificationFailure(
result skillmarketplace.InstallResult,
err error,
) {
h.Logger.Warn(
"skills marketplace: installed skill is not discoverable",
"name", result.Name,
"source", result.Registry,
"slug", result.Slug,
"path", result.Path,
"reason", err.Error(),
)
}
77 changes: 77 additions & 0 deletions internal/api/core/skills_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -364,6 +365,11 @@ func TestStatusForSkillMarketplaceError(t *testing.T) {
skillmarketplace.ErrNotConfigured,
http.StatusServiceUnavailable,
},
{
"Should return 503 when marketplace install is unavailable after discovery",
skillmarketplace.ErrUnavailable,
http.StatusServiceUnavailable,
},
{"Should return 500 for unknown error", http.ErrServerClosed, http.StatusInternalServerError},
}

Expand Down Expand Up @@ -516,11 +522,29 @@ func TestSkillMarketplaceHandlers(t *testing.T) {
t.Parallel()

refreshed := false
installedSkill := &skills.Skill{
Meta: skills.SkillMeta{
Name: "review",
},
Source: skills.SourceMarketplace,
Enabled: true,
Provenance: &skills.Provenance{
Slug: "@agh/review",
Registry: "clawhub",
Version: "1.2.0",
},
}
registry := &stubSkillsRegistry{
RefreshGlobalFn: func(context.Context) error {
refreshed = true
return nil
},
GetFn: func(name string) (*skills.Skill, bool) {
if name == "review" {
return installedSkill, true
}
return nil, false
},
}
marketplace := &stubSkillMarketplaceService{
InstallFn: func(_ context.Context, slug string, version string) (skillmarketplace.InstallResult, error) {
Expand Down Expand Up @@ -572,6 +596,59 @@ func TestSkillMarketplaceHandlers(t *testing.T) {
}
})

t.Run("Should reject marketplace install when refreshed registry cannot see skill", func(t *testing.T) {
t.Parallel()

registry := &stubSkillsRegistry{
RefreshGlobalFn: func(context.Context) error {
return nil
},
GetFn: func(string) (*skills.Skill, bool) {
return nil, false
},
}
marketplace := &stubSkillMarketplaceService{
InstallFn: func(context.Context, string, string) (skillmarketplace.InstallResult, error) {
return skillmarketplace.InstallResult{
Name: "review",
Slug: "@agh/review",
Version: "1.2.0",
Registry: "clawhub",
Path: "/tmp/agh/skills/review",
Hash: "sha256:abc",
Status: "installed",
}, nil
},
}
engine := newSkillsHandlerFixtureWithMarketplace(
t,
registry,
testutil.StubWorkspaceService{},
marketplace,
)
rec := testutil.PerformRequest(
t,
engine,
http.MethodPost,
"/api/skills/marketplace/install",
testutil.MustJSONBody(t, contract.SkillMarketplaceInstallRequest{
Slug: "@agh/review",
}),
)

if rec.Code != http.StatusServiceUnavailable {
t.Fatalf(
"status = %d, want %d; body=%s",
rec.Code,
http.StatusServiceUnavailable,
rec.Body.String(),
)
}
if !strings.Contains(rec.Body.String(), "not visible after skill discovery") {
t.Fatalf("body = %s, want registry visibility reason", rec.Body.String())
}
})

t.Run("Should not refresh registry for update check only", func(t *testing.T) {
t.Parallel()

Expand Down
41 changes: 41 additions & 0 deletions internal/cli/skill_marketplace.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
registryclawhub "github.com/compozy/agh/internal/registry/clawhub"
"github.com/compozy/agh/internal/skills"
skillmarketplace "github.com/compozy/agh/internal/skills/marketplace"
skillbundled "github.com/compozy/agh/skills"
)

const (
Expand Down Expand Up @@ -97,6 +98,9 @@ func installMarketplaceSkill(
if err != nil {
return skillInstallItem{}, err
}
if err := verifyInstalledMarketplaceSkill(ctx, runtime, result); err != nil {
return skillInstallItem{}, err
}
return skillInstallItem{
Name: result.Name,
Slug: result.Slug,
Expand All @@ -108,6 +112,43 @@ func installMarketplaceSkill(
}, nil
}

func verifyInstalledMarketplaceSkill(
ctx context.Context,
runtime *runtimeContext,
result skillmarketplace.InstallResult,
) error {
if runtime == nil {
return errors.New("cli: skill runtime context is required")
}

registry := skills.NewRegistry(skills.RegistryConfig{
BundledFS: skillbundled.FS(),
UserAgentsDir: runtime.HomePaths.AgentsDir,
UserSkillsDir: runtime.HomePaths.SkillsDir,
DisabledSkills: append([]string(nil), runtime.Config.Skills.DisabledSkills...),
}, skills.WithLogger(slog.Default()))
if err := registry.LoadAll(ctx); err != nil {
return fmt.Errorf("cli: reload skill registry after marketplace install %q: %w", result.Name, err)
}

if err := skillmarketplace.VerifyInstallVisible(registry, result); err != nil {
logSkillMarketplaceInstallVerificationFailure(result, err)
return fmt.Errorf("cli: %w", err)
}
return nil
}

func logSkillMarketplaceInstallVerificationFailure(result skillmarketplace.InstallResult, err error) {
slog.Warn(
"skills marketplace: installed skill is not discoverable",
"name", result.Name,
"source", result.Registry,
"slug", result.Slug,
"path", result.Path,
"reason", err.Error(),
)
}

func updateMarketplaceSkills(
ctx context.Context,
runtime *runtimeContext,
Expand Down
48 changes: 48 additions & 0 deletions internal/cli/skill_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
aghconfig "github.com/compozy/agh/internal/config"
registrypkg "github.com/compozy/agh/internal/registry"
"github.com/compozy/agh/internal/skills"
skillmarketplace "github.com/compozy/agh/internal/skills/marketplace"
"github.com/compozy/agh/internal/store/globaldb"
"github.com/compozy/agh/internal/testutil"
workspacepkg "github.com/compozy/agh/internal/workspace"
Expand Down Expand Up @@ -1514,6 +1515,53 @@ func TestSkillMarketplaceHelpers(t *testing.T) {
}
})

t.Run("Should install-marketplace-skill-rejects-disabled-discovery", func(t *testing.T) {
server := newMarketplaceTestServer(t, marketplaceServerFixture{
downloads: map[string]marketplaceDownloadFixture{
"@agh/review": {
version: "1.1.0",
files: map[string]string{
"review/SKILL.md": skillDocument("review", "Review helper", "body"),
},
},
},
})
defer server.Close()

env := newSkillTestEnv(t, func(cfg *aghconfig.Config) {
cfg.Skills.DisabledSkills = []string{"review"}
cfg.Skills.Marketplace = aghconfig.MarketplaceConfig{
Registry: "clawhub",
BaseURL: server.URL(),
}
})
runtime, registry, err := loadSkillRegistry(env.deps)
if err != nil {
t.Fatalf("loadSkillRegistry() error = %v", err)
}
defer func() {
if closeErr := registry.Close(); closeErr != nil {
t.Fatalf("registry.Close() error = %v", closeErr)
}
}()

_, err = installMarketplaceSkill(
testutil.Context(t),
runtime,
registry,
"@agh/review",
"",
"",
env.deps.now,
)
if err == nil {
t.Fatal("installMarketplaceSkill(disabled discovery) error = nil, want failure")
}
if !errors.Is(err, skillmarketplace.ErrUnavailable) {
t.Fatalf("installMarketplaceSkill(disabled discovery) error = %v, want ErrUnavailable", err)
}
})

t.Run("Should install-marketplace-skill-rejects-nil-archive", func(t *testing.T) {
env := newSkillTestEnv(t, nil)

Expand Down
76 changes: 76 additions & 0 deletions internal/skills/marketplace/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ var (
ErrNotFound = errors.New("skills marketplace: not found")
// ErrNotMarketplace classifies installed skills that lack marketplace provenance.
ErrNotMarketplace = errors.New("skills marketplace: not marketplace")
// ErrUnavailable classifies installs that are not visible through skill discovery.
ErrUnavailable = errors.New("skills marketplace: unavailable")
// ErrNotConfigured classifies missing or unsupported marketplace registry setup.
ErrNotConfigured = errors.New("skills marketplace: not configured")

Expand Down Expand Up @@ -99,6 +101,11 @@ type NamedRegistry interface {
SourceNamed(name string) registrypkg.Source
}

// SkillResolver resolves installed skills from the loaded AGH skill catalog.
type SkillResolver interface {
Get(name string) (*skills.Skill, bool)
}

// SourceLoader resolves configured marketplace sources.
type SourceLoader func(aghconfig.MarketplaceConfig) ([]registrypkg.Source, error)

Expand Down Expand Up @@ -984,6 +991,75 @@ func ResolveMarketplaceInstallTarget(
return registrypkg.PathWithinRoot(skillsDir, name)
}

// VerifyInstallVisible verifies that a completed marketplace install is visible
// through AGH skill discovery with the marketplace provenance that agents rely on.
func VerifyInstallVisible(resolver SkillResolver, result InstallResult) error {
if resolver == nil {
return classifiedf(
ErrUnavailable,
"installed marketplace skill %q cannot be verified because skill discovery is not configured",
result.Name,
)
}
skill, ok := resolver.Get(result.Name)
if !ok {
return classifiedf(
ErrUnavailable,
"installed marketplace skill %q is not visible after skill discovery; inspect %s and retry the install",
result.Name,
result.Path,
)
}
if skill.Source != skills.SourceMarketplace {
return classifiedf(
ErrUnavailable,
"installed marketplace skill %q resolved as %s%s after skill discovery; "+
"remove or disable that declaration and retry the install",
result.Name,
skills.SkillSourceName(skill.Source),
installVisibilityLocation(skill),
)
}
if skill.Provenance == nil {
return classifiedf(
ErrUnavailable,
"installed marketplace skill %q is missing provenance after skill discovery; inspect %s and retry the install",
result.Name,
result.Path,
)
}
if strings.TrimSpace(skill.Provenance.Slug) != strings.TrimSpace(result.Slug) {
return classifiedf(
ErrUnavailable,
"installed marketplace skill %q resolved slug %q after skill discovery, want %q; "+
"remove the conflicting skill and retry the install",
result.Name,
skill.Provenance.Slug,
result.Slug,
)
}
if !skill.Enabled {
return classifiedf(
ErrUnavailable,
"installed marketplace skill %q is visible but disabled after skill discovery; "+
"enable the skill and retry discovery",
result.Name,
)
}
return nil
}

func installVisibilityLocation(skill *skills.Skill) string {
if skill == nil {
return ""
}
path := firstNonEmpty(skill.FilePath, skill.Dir)
if path == "" {
return ""
}
return fmt.Sprintf(" from %s", path)
}

func (s *Service) loadRegistry() (*registrypkg.MultiRegistry, error) {
sources, err := s.sourceLoader(s.skillsConfig.Marketplace)
if err != nil {
Expand Down
Loading
Loading