diff --git a/docs/plugin-marketplace-compatibility.md b/docs/plugin-marketplace-compatibility.md index 179456c74..048c7733a 100644 --- a/docs/plugin-marketplace-compatibility.md +++ b/docs/plugin-marketplace-compatibility.md @@ -23,6 +23,8 @@ Only the URL/git source forms are covered (phase 1). Codex and Cursor require a Plugins that aren't `Ready` with a resolved source pin, or that resolved to an OCI source (no representation in this schema), are silently skipped — the document never contains a partial or broken entry. +**Composed plugins are also skipped** (logged at debug level). A plugin with composition refs (`spec.skills` / `spec.mcpServers` / `spec.commands` / `spec.instructions`) has no single upstream git URL, and serving just its base source would silently drop the overlays. Composed plugins are consumed via deploy-time materialization or `arctl plugin pull`; serving them to unmodified Claude Code is gated on git-backed marketplace hosting (tracked upstream). + ## Pointing a client at it ``` diff --git a/internal/registry/api/handlers/pluginmarketplace/handlers.go b/internal/registry/api/handlers/pluginmarketplace/handlers.go index c84032be7..4dbebf425 100644 --- a/internal/registry/api/handlers/pluginmarketplace/handlers.go +++ b/internal/registry/api/handlers/pluginmarketplace/handlers.go @@ -145,7 +145,16 @@ func getMarketplace(cfg Config) func(context.Context, *struct{}) (*types.Respons if err != nil { // Not-yet-resolved / unsupported-source plugins are silently // skipped: the marketplace.json document never contains a - // partial/broken entry. + // partial/broken entry. Composed plugins are a durable, + // by-design omission (no single upstream URL until git-backed + // marketplace hosting lands) — log those so operators can see + // why a plugin is absent from the catalogue. + if errors.Is(err, pluginmarketplace.ErrComposed) { + logger.Debug("plugin marketplace: skipping composed plugin (no single-source representation; consume via deploy or arctl)", + "namespace", p.Metadata.NamespaceOrDefault(), + "plugin_name", p.Metadata.Name, + "tag", p.Metadata.Tag) + } continue } if seenNames[entry.Name] { diff --git a/internal/registry/controller/plugin_components.go b/internal/registry/controller/plugin_components.go new file mode 100644 index 000000000..5475209a4 --- /dev/null +++ b/internal/registry/controller/plugin_components.go @@ -0,0 +1,224 @@ +package controller + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + + "github.com/agentregistry-dev/agentregistry/internal/registry/plugins/bundle" + "github.com/agentregistry-dev/agentregistry/internal/registry/plugins/compose" + "github.com/agentregistry-dev/agentregistry/pkg/api/v1alpha1" + pkgdb "github.com/agentregistry-dev/agentregistry/pkg/registry/database" + "github.com/agentregistry-dev/agentregistry/pkg/registry/v1alpha1store" +) + +// Component-resolution sentinels. Missing/invalid are TERMINAL (the user must +// change the spec); pending is RETRYABLE (the referenced object's own +// controller will pin it, so rate-limited backoff converges). +var ( + errComponentMissing = errors.New("plugin component missing") + errComponentInvalid = errors.New("plugin component invalid") + errComponentsPending = errors.New("plugin components pending") +) + +// componentGetter is the read-only, per-kind store access component +// resolution needs. *v1alpha1store.Store satisfies it. +type componentGetter interface { + Get(ctx context.Context, namespace, name, tag string) (*v1alpha1.RawObject, error) +} + +// treeFetcher fetches a git tree pinned at a commit (a referenced Skill's +// repo). source.FetchGitTree in production; a fake in tests. +type treeFetcher func(ctx context.Context, repo *v1alpha1.Repository, commit string) (*bundle.CanonicalBundle, error) + +// resolveComponents resolves every composition ref on p to (a) the compose +// inputs carrying the actual content and (b) the pin set recorded in status. +// Pins are returned in spec order: skills, mcpServers, commands, instructions. +func (c *PluginController) resolveComponents(ctx context.Context, p *v1alpha1.Plugin) (compose.Inputs, []v1alpha1.PluginResolvedComponent, error) { + in := compose.Inputs{ + PluginName: p.Metadata.Name, + Title: p.Spec.Title, + Description: p.Spec.Description, + } + var pins []v1alpha1.PluginResolvedComponent + ns := p.Metadata.NamespaceOrDefault() + + // Skill destinations are keyed by the SKILL.md-DECLARED name (the Agent + // Skills spec requires directory == declared name), so collisions between + // declared names can only be caught here, after content is fetched — + // admission sees only ref names. + declaredNames := map[string]v1alpha1.ComponentRef{} + for _, ref := range p.Spec.Skills { + skill, pin, err := c.resolveSkill(ctx, ns, ref) + if err != nil { + return in, nil, err + } + if prev, ok := declaredNames[skill.Name]; ok { + return in, nil, fmt.Errorf("%w: skills %s and %s both declare SKILL.md name %q (directory would collide)", + errComponentInvalid, componentID(v1alpha1.KindSkill, ns, prev), componentID(v1alpha1.KindSkill, ns, ref), skill.Name) + } + declaredNames[skill.Name] = ref + in.Skills = append(in.Skills, skill) + pins = append(pins, pin) + } + for _, ref := range p.Spec.MCPServers { + server, pin, err := c.resolveMCPServer(ctx, ns, ref) + if err != nil { + return in, nil, err + } + in.MCPServers = append(in.MCPServers, server) + pins = append(pins, pin) + } + for _, ref := range p.Spec.Commands { + body, pin, err := c.resolvePrompt(ctx, ns, ref) + if err != nil { + return in, nil, err + } + in.Commands = append(in.Commands, compose.Command{Name: ref.Name, Body: body}) + pins = append(pins, pin) + } + if ref := p.Spec.Instructions; ref != nil { + body, pin, err := c.resolvePrompt(ctx, ns, *ref) + if err != nil { + return in, nil, err + } + in.Instructions = &compose.Instructions{Name: ref.Name, Body: body} + pins = append(pins, pin) + } + return in, pins, nil +} + +// resolveSkill gates on the referenced Skill's own resolve-and-pin (its +// controller writes status.resolvedSource.commit) and fetches the pinned tree. +func (c *PluginController) resolveSkill(ctx context.Context, ns string, ref v1alpha1.ComponentRef) (compose.Skill, v1alpha1.PluginResolvedComponent, error) { + raw, err := c.getComponent(ctx, v1alpha1.KindSkill, ns, ref) + if err != nil { + return compose.Skill{}, v1alpha1.PluginResolvedComponent{}, err + } + skill, err := v1alpha1.EnvelopeFromRaw(func() *v1alpha1.Skill { return &v1alpha1.Skill{} }, raw, v1alpha1.KindSkill) + if err != nil { + return compose.Skill{}, v1alpha1.PluginResolvedComponent{}, fmt.Errorf("%w: decode skill %s: %v", errComponentInvalid, componentID(v1alpha1.KindSkill, ns, ref), err) + } + if skill.Spec.Source == nil || skill.Spec.Source.Repository == nil { + return compose.Skill{}, v1alpha1.PluginResolvedComponent{}, fmt.Errorf("%w: skill %s has no git source", errComponentInvalid, componentID(v1alpha1.KindSkill, ns, ref)) + } + if skill.Status.ResolvedSource == nil || skill.Status.ResolvedSource.Commit == "" { + return compose.Skill{}, v1alpha1.PluginResolvedComponent{}, fmt.Errorf("%w: skill %s not yet resolved", errComponentsPending, componentID(v1alpha1.KindSkill, ns, ref)) + } + commit := skill.Status.ResolvedSource.Commit + tree, err := c.fetchTree(ctx, skill.Spec.Source.Repository, commit) + if err != nil { + return compose.Skill{}, v1alpha1.PluginResolvedComponent{}, fmt.Errorf("fetch skill %s@%s: %w", componentID(v1alpha1.KindSkill, ns, ref), commit, err) + } + // The on-disk directory name is the SKILL.md-declared name (Agent Skills + // spec: directory MUST match the declared name), not the registry ref + // name. Missing/invalid declared names are terminal. + declared, err := bundle.DeclaredSkillName(tree.Files) + if err != nil { + return compose.Skill{}, v1alpha1.PluginResolvedComponent{}, fmt.Errorf("%w: skill %s@%s: %v", errComponentInvalid, componentID(v1alpha1.KindSkill, ns, ref), commit, err) + } + return compose.Skill{Name: declared, Files: tree.Files}, + componentPin(v1alpha1.KindSkill, ns, ref, commit, ""), nil +} + +// resolveMCPServer maps the referenced spec to its .mcp.json entry; shapes +// with no faithful desktop form are terminal. +func (c *PluginController) resolveMCPServer(ctx context.Context, ns string, ref v1alpha1.ComponentRef) (compose.MCPServer, v1alpha1.PluginResolvedComponent, error) { + raw, err := c.getComponent(ctx, v1alpha1.KindMCPServer, ns, ref) + if err != nil { + return compose.MCPServer{}, v1alpha1.PluginResolvedComponent{}, err + } + server, err := v1alpha1.EnvelopeFromRaw(func() *v1alpha1.MCPServer { return &v1alpha1.MCPServer{} }, raw, v1alpha1.KindMCPServer) + if err != nil { + return compose.MCPServer{}, v1alpha1.PluginResolvedComponent{}, fmt.Errorf("%w: decode mcp server %s: %v", errComponentInvalid, componentID(v1alpha1.KindMCPServer, ns, ref), err) + } + entry, err := compose.MCPEntryFromSpec(&server.Spec) + if err != nil { + return compose.MCPServer{}, v1alpha1.PluginResolvedComponent{}, fmt.Errorf("%w: mcp server %s: %v", errComponentInvalid, componentID(v1alpha1.KindMCPServer, ns, ref), err) + } + return compose.MCPServer{Name: ref.Name, Entry: entry}, + componentPin(v1alpha1.KindMCPServer, ns, ref, "", specHash(raw)), nil +} + +// resolvePrompt loads an inline Prompt body (commands and instructions). +func (c *PluginController) resolvePrompt(ctx context.Context, ns string, ref v1alpha1.ComponentRef) (string, v1alpha1.PluginResolvedComponent, error) { + raw, err := c.getComponent(ctx, v1alpha1.KindPrompt, ns, ref) + if err != nil { + return "", v1alpha1.PluginResolvedComponent{}, err + } + prompt, err := v1alpha1.EnvelopeFromRaw(func() *v1alpha1.Prompt { return &v1alpha1.Prompt{} }, raw, v1alpha1.KindPrompt) + if err != nil { + return "", v1alpha1.PluginResolvedComponent{}, fmt.Errorf("%w: decode prompt %s: %v", errComponentInvalid, componentID(v1alpha1.KindPrompt, ns, ref), err) + } + return prompt.Spec.Content, componentPin(v1alpha1.KindPrompt, ns, ref, "", specHash(raw)), nil +} + +// getComponent reads one referenced object, defaulting namespace to the +// plugin's and a blank tag to the literal latest tag. +func (c *PluginController) getComponent(ctx context.Context, kind, pluginNS string, ref v1alpha1.ComponentRef) (*v1alpha1.RawObject, error) { + getter := c.Components[kind] + if getter == nil { + return nil, fmt.Errorf("plugin controller: no store for component kind %s", kind) + } + ns, tag := componentNS(pluginNS, ref), componentTag(ref) + raw, err := getter.Get(ctx, ns, ref.Name, tag) + if errors.Is(err, pkgdb.ErrNotFound) { + return nil, fmt.Errorf("%w: %s %s/%s:%s not found", errComponentMissing, kind, ns, ref.Name, tag) + } + if err != nil { + return nil, fmt.Errorf("plugin controller: load %s %s/%s:%s: %w", kind, ns, ref.Name, tag, err) // retryable + } + return raw, nil +} + +func componentNS(pluginNS string, ref v1alpha1.ComponentRef) string { + if ref.Namespace != "" { + return ref.Namespace + } + return pluginNS +} + +func componentTag(ref v1alpha1.ComponentRef) string { + if ref.Tag != "" { + return ref.Tag + } + return v1alpha1store.DefaultTag() +} + +func componentID(kind, pluginNS string, ref v1alpha1.ComponentRef) string { + return fmt.Sprintf("%s %s/%s:%s", kind, componentNS(pluginNS, ref), ref.Name, componentTag(ref)) +} + +func componentPin(kind, pluginNS string, ref v1alpha1.ComponentRef, commit, contentHash string) v1alpha1.PluginResolvedComponent { + return v1alpha1.PluginResolvedComponent{ + Kind: kind, + Namespace: componentNS(pluginNS, ref), + Name: ref.Name, + Tag: componentTag(ref), + Commit: commit, + ContentHash: contentHash, + } +} + +// specHash pins an inline content kind: sha256 of the stored spec bytes. +func specHash(raw *v1alpha1.RawObject) string { + sum := sha256.Sum256(raw.Spec) + return hex.EncodeToString(sum[:]) +} + +// classifyComponentErr maps a component-resolution error to a status reason +// and terminality; falls back to the source classifier for fetch errors. +func classifyComponentErr(err error) (reason string, terminal bool) { + switch { + case errors.Is(err, errComponentMissing): + return "ComponentMissing", true + case errors.Is(err, errComponentInvalid): + return "ComponentInvalid", true + case errors.Is(err, errComponentsPending): + return "ComponentsPending", false + default: + return classifyResolveErr(err) + } +} diff --git a/internal/registry/controller/plugin_components_test.go b/internal/registry/controller/plugin_components_test.go new file mode 100644 index 000000000..f6c733037 --- /dev/null +++ b/internal/registry/controller/plugin_components_test.go @@ -0,0 +1,334 @@ +package controller + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/agentregistry-dev/agentregistry/internal/registry/plugins/bundle" + "github.com/agentregistry-dev/agentregistry/pkg/api/v1alpha1" + pkgdb "github.com/agentregistry-dev/agentregistry/pkg/registry/database" +) + +// fakeComponents is a per-kind componentGetter backed by a map keyed +// "ns/name:tag". +type fakeComponents map[string]*v1alpha1.RawObject + +func (f fakeComponents) Get(_ context.Context, ns, name, tag string) (*v1alpha1.RawObject, error) { + if raw, ok := f[ns+"/"+name+":"+tag]; ok { + return raw, nil + } + return nil, pkgdb.ErrNotFound +} + +func rawComponent(t *testing.T, kind, ns, name, tag string, spec any, status any) *v1alpha1.RawObject { + t.Helper() + specRaw, err := json.Marshal(spec) + if err != nil { + t.Fatal(err) + } + raw := &v1alpha1.RawObject{ + TypeMeta: v1alpha1.TypeMeta{APIVersion: v1alpha1.GroupVersion, Kind: kind}, + Metadata: v1alpha1.ObjectMeta{Namespace: ns, Name: name, Tag: tag, Generation: 1}, + Spec: specRaw, + } + if status != nil { + if raw.Status, err = json.Marshal(status); err != nil { + t.Fatal(err) + } + } + return raw +} + +func componentsFixture(t *testing.T, skillStatus any) map[string]componentGetter { + t.Helper() + skills := fakeComponents{ + "default/log-triage:v3": rawComponent(t, v1alpha1.KindSkill, "default", "log-triage", "v3", + v1alpha1.SkillSpec{Source: &v1alpha1.SkillSource{Repository: &v1alpha1.Repository{URL: "https://github.com/o/skill"}}}, + skillStatus), + } + mcps := fakeComponents{ + "default/pagerduty:latest": rawComponent(t, v1alpha1.KindMCPServer, "default", "pagerduty", "latest", + v1alpha1.MCPServerSpec{Remote: &v1alpha1.MCPRemote{Type: "http", URL: "https://mcp.example.com"}}, nil), + "default/oci-only:latest": rawComponent(t, v1alpha1.KindMCPServer, "default", "oci-only", "latest", + v1alpha1.MCPServerSpec{Source: &v1alpha1.MCPServerSource{Package: &v1alpha1.MCPPackage{ + Origin: v1alpha1.MCPPackageOrigin{Type: v1alpha1.MCPPackageOriginTypeOCI, Identifier: "ghcr.io/x/y@sha256:abc", OCI: &v1alpha1.MCPPackageOriginOCI{ServerName: "y"}}, + }}}, nil), + } + prompts := fakeComponents{ + "default/declare-incident:latest": rawComponent(t, v1alpha1.KindPrompt, "default", "declare-incident", "latest", + v1alpha1.PromptSpec{Content: "# declare"}, nil), + "default/guidelines:latest": rawComponent(t, v1alpha1.KindPrompt, "default", "guidelines", "latest", + v1alpha1.PromptSpec{Content: "follow the runbook"}, nil), + } + return map[string]componentGetter{ + v1alpha1.KindSkill: skills, + v1alpha1.KindMCPServer: mcps, + v1alpha1.KindPrompt: prompts, + } +} + +func fakeTree(files map[string][]byte) treeFetcher { + return func(context.Context, *v1alpha1.Repository, string) (*bundle.CanonicalBundle, error) { + return &bundle.CanonicalBundle{Files: files}, nil + } +} + +func composedPlugin(gen int64) *v1alpha1.Plugin { + return &v1alpha1.Plugin{ + Metadata: v1alpha1.ObjectMeta{Namespace: "default", Name: "ir", Tag: "v1", Generation: gen}, + Spec: v1alpha1.PluginSpec{ + Title: "Incident Response", + Skills: []v1alpha1.ComponentRef{{Name: "log-triage", Tag: "v3"}}, + MCPServers: []v1alpha1.ComponentRef{{Name: "pagerduty"}}, + Commands: []v1alpha1.ComponentRef{{Name: "declare-incident"}}, + Instructions: &v1alpha1.ComponentRef{Name: "guidelines"}, + }, + } +} + +func TestPluginReconcile_PureComposition(t *testing.T) { + store := newFakePluginStore() + skillStatus := map[string]any{"resolvedSource": map[string]string{"commit": strings.Repeat("a", 40)}} + c := &PluginController{ + Store: store, + Resolver: fakeResolver{}, // must not be consulted: no base source + Components: componentsFixture(t, skillStatus), + fetchTree: fakeTree(map[string][]byte{"SKILL.md": []byte("---\nname: log-triage\n---\ntriage")}), + } + outcome, _, err := c.reconcile(context.Background(), composedPlugin(2)) + if err != nil || outcome != "resolved" { + t.Fatalf("reconcile = (%q, %v), want (resolved, nil)", outcome, err) + } + got := store.plugin(t, "default", "ir", "v1") + if !got.Status.IsConditionTrue(pluginReadyCondition) { + t.Fatal("expected Ready=True") + } + if got.Status.ResolvedSource != nil { + t.Errorf("pure composition must have nil resolvedSource, got %+v", got.Status.ResolvedSource) + } + pins := got.Status.ResolvedComponents + if len(pins) != 4 { + t.Fatalf("expected 4 pins, got %+v", pins) + } + // Spec order: skills, mcpServers, commands, instructions. + if pins[0].Kind != v1alpha1.KindSkill || pins[0].Tag != "v3" || pins[0].Commit != strings.Repeat("a", 40) { + t.Errorf("skill pin = %+v", pins[0]) + } + if pins[1].Kind != v1alpha1.KindMCPServer || pins[1].Tag != "latest" || pins[1].ContentHash == "" { + t.Errorf("mcp pin = %+v", pins[1]) + } + if pins[2].Kind != v1alpha1.KindPrompt || pins[2].Name != "declare-incident" { + t.Errorf("command pin = %+v", pins[2]) + } + if pins[3].Kind != v1alpha1.KindPrompt || pins[3].Name != "guidelines" { + t.Errorf("instructions pin = %+v", pins[3]) + } + // Manifest generated (no base) + inventory reflects the composed bundle. + if got.Status.Manifest == nil || got.Status.Manifest.Name != "ir" { + t.Errorf("generated manifest = %+v", got.Status.Manifest) + } + inv := got.Status.Inventory + if inv == nil || len(inv.Skills) != 1 || inv.Skills[0].Name != "log-triage" { + t.Errorf("inventory skills = %+v", inv) + } + if len(inv.Commands) != 1 || inv.Commands[0] != "declare-incident" { + t.Errorf("inventory commands = %+v", inv) + } + if len(inv.MCPServers) != 1 || inv.MCPServers[0] != "pagerduty" { + t.Errorf("inventory mcpServers = %+v", inv) + } +} + +func TestPluginReconcile_SkillPendingIsRetryable(t *testing.T) { + store := newFakePluginStore() + c := &PluginController{ + Store: store, + Resolver: fakeResolver{}, + Components: componentsFixture(t, nil), // skill has no resolvedSource yet + fetchTree: fakeTree(nil), + } + _, _, err := c.reconcile(context.Background(), composedPlugin(2)) + if err == nil || !errors.Is(err, errComponentsPending) { + t.Fatalf("expected retryable ComponentsPending error, got %v", err) + } + got := store.plugin(t, "default", "ir", "v1") + if got.Status.ObservedGeneration != 0 { + t.Errorf("retryable must NOT bump observedGeneration, got %d", got.Status.ObservedGeneration) + } + if r := readyReason(got); r != "ComponentsPending" { + t.Errorf("ready reason = %q, want ComponentsPending", r) + } +} + +func TestPluginReconcile_MissingComponentIsTerminal(t *testing.T) { + store := newFakePluginStore() + p := composedPlugin(3) + p.Spec.Skills = []v1alpha1.ComponentRef{{Name: "does-not-exist"}} + c := &PluginController{ + Store: store, + Resolver: fakeResolver{}, + Components: componentsFixture(t, nil), + fetchTree: fakeTree(nil), + } + outcome, reason, err := c.reconcile(context.Background(), p) + if err != nil { + t.Fatalf("terminal must Forget, got %v", err) + } + if outcome != "failed" || reason != "ComponentMissing" { + t.Fatalf("got (%q, %q), want (failed, ComponentMissing)", outcome, reason) + } + if got := store.plugin(t, "default", "ir", "v1"); got.Status.ObservedGeneration != 3 { + t.Errorf("terminal must bump observedGeneration, got %d", got.Status.ObservedGeneration) + } +} + +func TestPluginReconcile_OCIPackageMCPIsTerminalInvalid(t *testing.T) { + store := newFakePluginStore() + p := composedPlugin(4) + p.Spec.Skills = nil + p.Spec.Commands = nil + p.Spec.Instructions = nil + p.Spec.MCPServers = []v1alpha1.ComponentRef{{Name: "oci-only"}} + c := &PluginController{ + Store: store, + Resolver: fakeResolver{}, + Components: componentsFixture(t, nil), + fetchTree: fakeTree(nil), + } + outcome, reason, err := c.reconcile(context.Background(), p) + if err != nil { + t.Fatalf("terminal must Forget, got %v", err) + } + if outcome != "failed" || reason != "ComponentInvalid" { + t.Fatalf("got (%q, %q), want (failed, ComponentInvalid)", outcome, reason) + } +} + +func TestPluginReconcile_BasePlusOverlayComposes(t *testing.T) { + store := newFakePluginStore() + skillStatus := map[string]any{"resolvedSource": map[string]string{"commit": strings.Repeat("b", 40)}} + p := composedPlugin(5) + p.Spec.MCPServers, p.Spec.Commands, p.Spec.Instructions = nil, nil, nil + p.Spec.Source = &v1alpha1.PluginSource{ + Type: v1alpha1.PluginSourceTypeGit, + Git: &v1alpha1.PluginSourceGit{Repository: &v1alpha1.Repository{URL: "https://github.com/o/base", Branch: "main"}}, + } + baseManifest := `{"name":"base-plugin"}` + c := &PluginController{ + Store: store, + Resolver: fakeResolver{ + resolved: &v1alpha1.PluginResolvedSource{Type: v1alpha1.PluginSourceTypeGit, Commit: "basecommit"}, + bundle: &bundle.CanonicalBundle{Files: map[string][]byte{ + ".claude-plugin/plugin.json": []byte(baseManifest), + "skills/log-triage/SKILL.md": []byte("---\nname: log-triage\n---\nold"), + }}, + }, + Components: componentsFixture(t, skillStatus), + fetchTree: fakeTree(map[string][]byte{"SKILL.md": []byte("---\nname: log-triage\ndescription: curated\n---\nnew")}), + } + outcome, _, err := c.reconcile(context.Background(), p) + if err != nil || outcome != "resolved" { + t.Fatalf("reconcile = (%q, %v), want (resolved, nil)", outcome, err) + } + got := store.plugin(t, "default", "ir", "v1") + if got.Status.ResolvedSource == nil || got.Status.ResolvedSource.Commit != "basecommit" { + t.Errorf("resolvedSource = %+v", got.Status.ResolvedSource) + } + // Base manifest passes through untouched. + if got.Status.Manifest == nil || got.Status.Manifest.Name != "base-plugin" { + t.Errorf("manifest = %+v", got.Status.Manifest) + } + // Inventory reflects the overlay-wins result: the curated skill's + // description, not the base's. + inv := got.Status.Inventory + if inv == nil || len(inv.Skills) != 1 || inv.Skills[0].Description != "curated" { + t.Errorf("inventory = %+v", inv) + } +} + +// TestPluginReconcile_SkillDestUsesDeclaredName guards the cross-lane naming +// contract (BYO composition doc / Agent Skills spec): the skills// +// directory is keyed by the SKILL.md-declared name, not the registry ref name. +func TestPluginReconcile_SkillDestUsesDeclaredName(t *testing.T) { + store := newFakePluginStore() + skillStatus := map[string]any{"resolvedSource": map[string]string{"commit": strings.Repeat("c", 40)}} + p := composedPlugin(2) + p.Spec.MCPServers, p.Spec.Commands, p.Spec.Instructions = nil, nil, nil + // Ref name "log-triage" but the fetched tree declares "triage-pro". + c := &PluginController{ + Store: store, + Resolver: fakeResolver{}, + Components: componentsFixture(t, skillStatus), + fetchTree: fakeTree(map[string][]byte{"SKILL.md": []byte("---\nname: triage-pro\n---\nbody")}), + } + outcome, _, err := c.reconcile(context.Background(), p) + if err != nil || outcome != "resolved" { + t.Fatalf("reconcile = (%q, %v), want (resolved, nil)", outcome, err) + } + got := store.plugin(t, "default", "ir", "v1") + inv := got.Status.Inventory + if inv == nil || len(inv.Skills) != 1 || inv.Skills[0].Name != "triage-pro" { + t.Errorf("inventory should reflect the declared name, got %+v", inv) + } + // The pin keeps the REGISTRY identity (ref name), for re-resolution. + if len(got.Status.ResolvedComponents) != 1 || got.Status.ResolvedComponents[0].Name != "log-triage" { + t.Errorf("pin should keep the ref name, got %+v", got.Status.ResolvedComponents) + } +} + +// TestPluginReconcile_InvalidDeclaredSkillNameIsTerminal: a fetched skill tree +// with no/invalid SKILL.md name cannot be placed spec-compliantly. +func TestPluginReconcile_InvalidDeclaredSkillNameIsTerminal(t *testing.T) { + store := newFakePluginStore() + skillStatus := map[string]any{"resolvedSource": map[string]string{"commit": strings.Repeat("d", 40)}} + p := composedPlugin(3) + p.Spec.MCPServers, p.Spec.Commands, p.Spec.Instructions = nil, nil, nil + c := &PluginController{ + Store: store, + Resolver: fakeResolver{}, + Components: componentsFixture(t, skillStatus), + fetchTree: fakeTree(map[string][]byte{"SKILL.md": []byte("---\nname: Bad--Name\n---\n")}), + } + outcome, reason, err := c.reconcile(context.Background(), p) + if err != nil { + t.Fatalf("terminal must Forget, got %v", err) + } + if outcome != "failed" || reason != "ComponentInvalid" { + t.Fatalf("got (%q, %q), want (failed, ComponentInvalid)", outcome, reason) + } +} + +// TestPluginReconcile_DeclaredNameCollisionIsTerminal: two refs whose trees +// declare the same SKILL.md name would collide at skills//. +func TestPluginReconcile_DeclaredNameCollisionIsTerminal(t *testing.T) { + store := newFakePluginStore() + commit := strings.Repeat("e", 40) + skills := fakeComponents{ + "default/skill-a:latest": rawComponent(t, v1alpha1.KindSkill, "default", "skill-a", "latest", + v1alpha1.SkillSpec{Source: &v1alpha1.SkillSource{Repository: &v1alpha1.Repository{URL: "https://github.com/o/a"}}}, + map[string]any{"resolvedSource": map[string]string{"commit": commit}}), + "default/skill-b:latest": rawComponent(t, v1alpha1.KindSkill, "default", "skill-b", "latest", + v1alpha1.SkillSpec{Source: &v1alpha1.SkillSource{Repository: &v1alpha1.Repository{URL: "https://github.com/o/b"}}}, + map[string]any{"resolvedSource": map[string]string{"commit": commit}}), + } + p := composedPlugin(4) + p.Spec.Skills = []v1alpha1.ComponentRef{{Name: "skill-a"}, {Name: "skill-b"}} + p.Spec.MCPServers, p.Spec.Commands, p.Spec.Instructions = nil, nil, nil + c := &PluginController{ + Store: store, + Resolver: fakeResolver{}, + Components: map[string]componentGetter{v1alpha1.KindSkill: skills}, + // Both trees declare the same name. + fetchTree: fakeTree(map[string][]byte{"SKILL.md": []byte("---\nname: same-name\n---\n")}), + } + outcome, reason, err := c.reconcile(context.Background(), p) + if err != nil { + t.Fatalf("terminal must Forget, got %v", err) + } + if outcome != "failed" || reason != "ComponentInvalid" { + t.Fatalf("got (%q, %q), want (failed, ComponentInvalid)", outcome, reason) + } +} diff --git a/internal/registry/controller/plugin_controller.go b/internal/registry/controller/plugin_controller.go index c1829adec..cb3ef3363 100644 --- a/internal/registry/controller/plugin_controller.go +++ b/internal/registry/controller/plugin_controller.go @@ -12,6 +12,7 @@ import ( "k8s.io/client-go/util/workqueue" "github.com/agentregistry-dev/agentregistry/internal/registry/plugins/bundle" + "github.com/agentregistry-dev/agentregistry/internal/registry/plugins/compose" "github.com/agentregistry-dev/agentregistry/internal/registry/plugins/source" "github.com/agentregistry-dev/agentregistry/pkg/api/v1alpha1" pkgdb "github.com/agentregistry-dev/agentregistry/pkg/registry/database" @@ -19,9 +20,12 @@ import ( ) // PluginControllerDeps are the Plugin controller's dependencies. Resolver pins -// a plugin's source pointer and loads its bundle; it is required. +// a plugin's base source pointer and loads its bundle; it is required. +// FetchTree fetches a referenced Skill's repo at its pinned commit; nil +// defaults to source.FetchGitTree. type PluginControllerDeps struct { - Resolver source.Resolver + Resolver source.Resolver + FetchTree treeFetcher } // pluginStore is the subset of *v1alpha1store.Store the controller uses, @@ -54,7 +58,12 @@ type pluginQueueKey struct { type PluginController struct { Store pluginStore Resolver source.Resolver - Wakeups <-chan struct{} + // Components provides read access to the kinds composition refs point at + // (Skill, MCPServer, Prompt). + Components map[string]componentGetter + // fetchTree fetches a referenced Skill's pinned git tree. + fetchTree treeFetcher + Wakeups <-chan struct{} pool *pgxpool.Pool resync time.Duration @@ -84,11 +93,25 @@ func NewPluginController( if deps.Resolver == nil { return nil, errors.New("plugin controller: Resolver is required") } + components := map[string]componentGetter{} + for _, kind := range []string{v1alpha1.KindSkill, v1alpha1.KindMCPServer, v1alpha1.KindPrompt} { + s := stores[kind] + if s == nil { + return nil, fmt.Errorf("plugin controller: %s store is required for composition", kind) + } + components[kind] = s + } + fetch := deps.FetchTree + if fetch == nil { + fetch = source.FetchGitTree + } return &PluginController{ - Store: store, - Resolver: deps.Resolver, - pool: pool, - resync: defaultControllerResyncInterval, + Store: store, + Resolver: deps.Resolver, + Components: components, + fetchTree: fetch, + pool: pool, + resync: defaultControllerResyncInterval, }, nil } @@ -314,38 +337,71 @@ func (c *PluginController) reconcile(ctx context.Context, p *v1alpha1.Plugin) (s } } - resolved, b, err := c.Resolver.Resolve(ctx, p) - if err != nil { - reason, terminal := classifyResolveErr(err) - bump := int64(0) - if terminal { - bump = gen - } - patchErr := c.patchStatus(ctx, ns, name, tag, bump, func(st *v1alpha1.PluginStatus) { - setReady(st, v1alpha1.ConditionFalse, reason, err.Error()) - }) - if terminal { - return "failed", reason, patchErr + // Base source (optional since composition landed): resolve-and-pin as + // before. A pure-composition plugin skips this entirely. + var resolved *v1alpha1.PluginResolvedSource + var b *bundle.CanonicalBundle + if p.Spec.Source != nil { + var err error + resolved, b, err = c.Resolver.Resolve(ctx, p) + if err != nil { + reason, terminal := classifyResolveErr(err) + return c.failStatus(ctx, ns, name, tag, gen, reason, err, terminal) } - return "", "", err // retryable } - manifest, err := bundle.ParseManifest(b) + // Composition refs: resolve each to its pin + content. Pending components + // (a referenced Skill its own controller hasn't pinned yet) are retryable. + comps, pins, err := c.resolveComponents(ctx, p) + if err != nil { + reason, terminal := classifyComponentErr(err) + return c.failStatus(ctx, ns, name, tag, gen, reason, err, terminal) + } + + // Compile: flatten base + components. Manifest/Inventory are computed over + // the COMPOSED bundle so status reflects the true surface. + comps.Base = b + composed, _, err := compose.Compose(comps) if err != nil { - return "failed", "SourceInvalid", c.patchStatus(ctx, ns, name, tag, gen, func(st *v1alpha1.PluginStatus) { - setReady(st, v1alpha1.ConditionFalse, "SourceInvalid", err.Error()) - }) + return c.failStatus(ctx, ns, name, tag, gen, "SourceInvalid", err, true) } - inventory := bundle.BuildInventory(b) + + manifest, err := bundle.ParseManifest(composed) + if err != nil { + return c.failStatus(ctx, ns, name, tag, gen, "SourceInvalid", err, true) + } + inventory := bundle.BuildInventory(composed) return "resolved", "", c.patchStatus(ctx, ns, name, tag, gen, func(st *v1alpha1.PluginStatus) { st.ResolvedSource = resolved + st.ResolvedComponents = pins st.Manifest = manifest st.Inventory = inventory setReady(st, v1alpha1.ConditionTrue, "Resolved", "") }) } +// failStatus records a reconcile failure on the Ready condition. Terminal +// failures advance ObservedGeneration (no re-resolve until the spec changes) +// and Forget; retryable ones leave it behind and return the error for +// rate-limited backoff. +func (c *PluginController) failStatus(ctx context.Context, ns, name, tag string, gen int64, reason string, cause error, terminal bool) (string, string, error) { + bump := int64(0) + if terminal { + bump = gen + } + patchErr := c.patchStatus(ctx, ns, name, tag, bump, func(st *v1alpha1.PluginStatus) { + setReady(st, v1alpha1.ConditionFalse, reason, cause.Error()) + }) + if terminal { + return "failed", reason, patchErr + } + if patchErr != nil { + return "", "", patchErr + } + return "", "", cause // retryable +} + // classifyResolveErr maps a resolver error to a status reason and whether it is // terminal (Forget) or retryable (rate-limited requeue). func classifyResolveErr(err error) (reason string, terminal bool) { diff --git a/internal/registry/plugins/bundle/manifest.go b/internal/registry/plugins/bundle/manifest.go index bf760628c..1b8d1d25b 100644 --- a/internal/registry/plugins/bundle/manifest.go +++ b/internal/registry/plugins/bundle/manifest.go @@ -5,6 +5,7 @@ import ( "fmt" "maps" "path" + "regexp" "slices" "strings" @@ -65,6 +66,33 @@ func BuildInventory(b *CanonicalBundle) *v1alpha1.PluginInventory { return m } +// DeclaredSkillName returns the name a standalone skill tree declares in its +// root SKILL.md frontmatter, validated against the Agent Skills spec's name +// rules (agentskills.io: 1-64 chars; lowercase letters, digits, hyphens; no +// leading/trailing/consecutive hyphens). The spec requires a skill's directory +// name to MATCH this declared name, so consumers placing the tree (e.g. plugin +// composition at skills//) must key on it — a registry ref's name is +// registry identity, not the on-disk name. Missing SKILL.md, missing name, or +// a spec-violating name is an ErrInvalidBundle. +func DeclaredSkillName(files map[string][]byte) (string, error) { + content, ok := files["SKILL.md"] + if !ok { + return "", fmt.Errorf("%w: skill tree has no root SKILL.md", ErrInvalidBundle) + } + name, _ := parseSkillFrontmatter(content) + if name == "" { + return "", fmt.Errorf("%w: SKILL.md frontmatter declares no name", ErrInvalidBundle) + } + if !skillNameRegex.MatchString(name) || strings.Contains(name, "--") { + return "", fmt.Errorf("%w: skill name %q violates the Agent Skills spec name rules (1-64 chars; lowercase letters, digits, hyphens; no leading/trailing/consecutive hyphens)", ErrInvalidBundle, name) + } + return name, nil +} + +// skillNameRegex encodes the Agent Skills spec name charset/anchoring; the +// no-consecutive-hyphens rule is checked separately for a clearer error. +var skillNameRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$`) + // parseSkillFrontmatter extracts name/description from a SKILL.md YAML // frontmatter block (--- ... ---). Returns empties on any parse failure. func parseSkillFrontmatter(content []byte) (name, desc string) { diff --git a/internal/registry/plugins/bundle/manifest_test.go b/internal/registry/plugins/bundle/manifest_test.go index 167e5dfa3..76cbb5ab7 100644 --- a/internal/registry/plugins/bundle/manifest_test.go +++ b/internal/registry/plugins/bundle/manifest_test.go @@ -1,7 +1,9 @@ package bundle import ( + "errors" "reflect" + "strings" "testing" "github.com/agentregistry-dev/agentregistry/pkg/api/v1alpha1" @@ -82,3 +84,42 @@ func TestParseManifest(t *testing.T) { t.Fatal("expected error for malformed manifest") } } + +func TestDeclaredSkillName(t *testing.T) { + tree := func(frontmatter string) map[string][]byte { + return map[string][]byte{"SKILL.md": []byte(frontmatter)} + } + tests := []struct { + name string + files map[string][]byte + want string + wantErr string + }{ + {"valid", tree("---\nname: log-triage\n---\nbody"), "log-triage", ""}, + {"valid single char", tree("---\nname: a\n---\n"), "a", ""}, + {"missing SKILL.md", map[string][]byte{"README.md": []byte("x")}, "", "no root SKILL.md"}, + {"no frontmatter name", tree("---\ndescription: d\n---\n"), "", "declares no name"}, + {"uppercase rejected", tree("---\nname: Log-Triage\n---\n"), "", "name rules"}, + {"leading hyphen rejected", tree("---\nname: -triage\n---\n"), "", "name rules"}, + {"trailing hyphen rejected", tree("---\nname: triage-\n---\n"), "", "name rules"}, + {"consecutive hyphens rejected", tree("---\nname: log--triage\n---\n"), "", "name rules"}, + {"too long rejected", tree("---\nname: " + strings.Repeat("a", 65) + "\n---\n"), "", "name rules"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DeclaredSkillName(tt.files) + if tt.wantErr == "" { + if err != nil || got != tt.want { + t.Fatalf("DeclaredSkillName = (%q, %v), want (%q, nil)", got, err, tt.want) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + if !errors.Is(err, ErrInvalidBundle) { + t.Errorf("error must wrap ErrInvalidBundle, got %v", err) + } + }) + } +} diff --git a/internal/registry/plugins/compose/compose.go b/internal/registry/plugins/compose/compose.go new file mode 100644 index 000000000..2df026f99 --- /dev/null +++ b/internal/registry/plugins/compose/compose.go @@ -0,0 +1,305 @@ +// Package compose flattens a composed plugin — an optional base bundle plus +// resolved registry components — into one canonical bundle. It is the compile +// step of plugin composability: a PURE function of its inputs (no I/O, no +// clock, no randomness), so any consumer holding the same pin set reproduces a +// byte-identical result. +// +// Placement rules (design: design-docs/PLUGIN_COMPOSABILITY_SPIKE.md §6): +// +// - skill → skills//** whole-directory overlay-wins over base +// - command → commands/.md whole-file overlay-wins +// - mcp server → .mcp.json keyed merge; overlay replaces same name +// - instructions → AGENTS.md append with a separator +// - base .claude-plugin/plugin.json passes through untouched; a minimal +// manifest is generated only when there is no base at all +// +// Every overlay-wins replacement is recorded in the Report so shadowing is +// visible, never silent. +package compose + +import ( + "encoding/json" + "fmt" + "maps" + "path" + "slices" + "strings" + + "github.com/agentregistry-dev/agentregistry/internal/registry/plugins/bundle" + "github.com/agentregistry-dev/agentregistry/pkg/api/v1alpha1" +) + +const ( + mcpConfigPath = ".mcp.json" + agentsPath = "AGENTS.md" + manifestPath = ".claude-plugin/plugin.json" + instructionsSep = "\n\n---\n\n" + skillsPrefix = "skills/" + commandsPrefix = "commands/" + commandExtension = ".md" +) + +// Inputs are the resolved pieces the controller hands to Compose. The typed +// fields mirror PluginSpec's composition block; each field's destination is +// disjoint from the others, so only intra-field order matters (and duplicate +// names within a field are rejected at admission). +type Inputs struct { + // Base is the plugin's optional base bundle (nil for pure composition). + Base *bundle.CanonicalBundle + + Skills []Skill + MCPServers []MCPServer + Commands []Command + Instructions *Instructions + + // Plugin identity, used only to generate a minimal manifest when Base is + // nil. + PluginName string + Title string + Description string +} + +// Skill is one resolved Skill component: the file tree of its pinned repo. +type Skill struct { + // Name is the skill's SKILL.md-DECLARED name (bundle.DeclaredSkillName), + // not the registry ref name — the Agent Skills spec requires the + // skills// directory to match the declared name. The caller derives + // and validates it before composing. + Name string + // Files is the skill repo's tree (SKILL.md at the root), path-keyed the + // same way as CanonicalBundle.Files. + Files map[string][]byte +} + +// MCPServer is one resolved MCPServer component, already mapped to its +// .mcp.json entry form. +type MCPServer struct { + Name string + // Entry is the JSON object placed at mcpServers[name]. + Entry json.RawMessage +} + +// Command is one resolved Prompt-backed slash command. +type Command struct { + Name string + Body string +} + +// Instructions is the resolved Prompt appended to AGENTS.md. +type Instructions struct { + Name string + Body string +} + +// Report is the provenance record of one compose run. Empty slices mean a +// clean overlay with nothing shadowed. +type Report struct { + // Placed lists every composed component and where it landed. + Placed []Placement + // Replaced lists base content an overlay replaced (overlay-wins events). + Replaced []Replacement +} + +// Placement records one component's destination. +type Placement struct { + Kind string // v1alpha1 kind of the backing artifact + Name string + Dest string // bundle path (directory prefix for skills) +} + +// Replacement records base content replaced by an overlay component. +type Replacement struct { + Kind string + Name string + Dest string + // Files is how many base files were replaced/removed at Dest. + Files int +} + +// Compose flattens inputs into a new canonical bundle. The base is never +// mutated. The result respects the bundle file-count/byte ceilings. +func Compose(in Inputs) (*bundle.CanonicalBundle, *Report, error) { + files := map[string][]byte{} + if in.Base != nil { + maps.Copy(files, in.Base.Files) + } + report := &Report{} + + for _, s := range in.Skills { + if err := overlaySkill(files, s, report); err != nil { + return nil, nil, err + } + } + for _, c := range in.Commands { + overlayCommand(files, c, report) + } + if len(in.MCPServers) > 0 { + if err := mergeMCPServers(files, in.MCPServers, report); err != nil { + return nil, nil, err + } + } + if in.Instructions != nil { + appendInstructions(files, *in.Instructions, report) + } + if in.Base == nil { + if err := generateManifest(files, in); err != nil { + return nil, nil, err + } + } + + if err := checkCeilings(files); err != nil { + return nil, nil, err + } + return &bundle.CanonicalBundle{Files: files}, report, nil +} + +// overlaySkill places s's tree at skills//, atomically replacing any +// same-named base directory (whole-directory overlay-wins — base and overlay +// trees are never interleaved). +func overlaySkill(files map[string][]byte, s Skill, report *Report) error { + dest := skillsPrefix + s.Name + "/" + removed := 0 + for _, p := range sortedKeys(files) { + if strings.HasPrefix(p, dest) { + delete(files, p) + removed++ + } + } + if removed > 0 { + report.Replaced = append(report.Replaced, Replacement{Kind: v1alpha1.KindSkill, Name: s.Name, Dest: dest, Files: removed}) + } + for _, rel := range sortedKeys(s.Files) { + if err := validateComponentPath(rel); err != nil { + return fmt.Errorf("skill %q: %w", s.Name, err) + } + files[dest+rel] = s.Files[rel] + } + report.Placed = append(report.Placed, Placement{Kind: v1alpha1.KindSkill, Name: s.Name, Dest: dest}) + return nil +} + +// overlayCommand writes the command markdown at commands/.md, +// replacing a same-named base file (whole-file overlay-wins). +func overlayCommand(files map[string][]byte, c Command, report *Report) { + dest := commandsPrefix + c.Name + commandExtension + if _, ok := files[dest]; ok { + report.Replaced = append(report.Replaced, Replacement{Kind: v1alpha1.KindPrompt, Name: c.Name, Dest: dest, Files: 1}) + } + files[dest] = []byte(c.Body) + report.Placed = append(report.Placed, Placement{Kind: v1alpha1.KindPrompt, Name: c.Name, Dest: dest}) +} + +// mergeMCPServers performs the keyed structured merge into .mcp.json. The +// base document's unrelated top-level fields and unrelated server entries are +// preserved byte-for-byte; an overlay entry replaces a same-named base entry. +func mergeMCPServers(files map[string][]byte, servers []MCPServer, report *Report) error { + doc := map[string]json.RawMessage{} + entries := map[string]json.RawMessage{} + if raw, ok := files[mcpConfigPath]; ok { + if err := json.Unmarshal(raw, &doc); err != nil { + return fmt.Errorf("%w: base %s is not a JSON object: %v", bundle.ErrInvalidBundle, mcpConfigPath, err) + } + if rawServers, ok := doc["mcpServers"]; ok { + if err := json.Unmarshal(rawServers, &entries); err != nil { + return fmt.Errorf("%w: base %s mcpServers is not an object: %v", bundle.ErrInvalidBundle, mcpConfigPath, err) + } + } + } + for _, s := range servers { + if _, ok := entries[s.Name]; ok { + report.Replaced = append(report.Replaced, Replacement{Kind: v1alpha1.KindMCPServer, Name: s.Name, Dest: mcpConfigPath, Files: 1}) + } + entries[s.Name] = s.Entry + report.Placed = append(report.Placed, Placement{Kind: v1alpha1.KindMCPServer, Name: s.Name, Dest: mcpConfigPath}) + } + rawServers, err := json.Marshal(entries) // map keys marshal sorted: deterministic + if err != nil { + return fmt.Errorf("encode %s mcpServers: %w", mcpConfigPath, err) + } + doc["mcpServers"] = rawServers + raw, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return fmt.Errorf("encode %s: %w", mcpConfigPath, err) + } + files[mcpConfigPath] = append(raw, '\n') + return nil +} + +// appendInstructions appends the prompt body to AGENTS.md (creating it when +// absent), separated from existing content. +func appendInstructions(files map[string][]byte, ins Instructions, report *Report) { + body := strings.TrimRight(ins.Body, "\n") + "\n" + if existing, ok := files[agentsPath]; ok && len(existing) > 0 { + files[agentsPath] = append(append( + []byte(strings.TrimRight(string(existing), "\n")), []byte(instructionsSep)...), body...) + } else { + files[agentsPath] = []byte(body) + } + report.Placed = append(report.Placed, Placement{Kind: v1alpha1.KindPrompt, Name: ins.Name, Dest: agentsPath}) +} + +// generateManifest writes a minimal .claude-plugin/plugin.json for a pure +// composition (no base). A base bundle's manifest always passes through +// untouched instead. +func generateManifest(files map[string][]byte, in Inputs) error { + m := struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + }{Name: in.PluginName, Description: in.Description} + if m.Description == "" { + m.Description = in.Title + } + raw, err := json.MarshalIndent(m, "", " ") + if err != nil { + return fmt.Errorf("encode generated manifest: %w", err) + } + files[manifestPath] = append(raw, '\n') + return nil +} + +// checkCeilings enforces the bundle size bounds on the composed result — the +// per-source bounds in bundle.FromDir don't cover the sum of layers. +func checkCeilings(files map[string][]byte) error { + if len(files) > bundle.MaxBundleFiles { + return fmt.Errorf("%w: composed bundle has too many files (limit %d)", bundle.ErrInvalidBundle, bundle.MaxBundleFiles) + } + var total int64 + for _, b := range files { + total += int64(len(b)) + } + if total > bundle.MaxBundleBytes { + return fmt.Errorf("%w: composed bundle exceeds %d bytes", bundle.ErrInvalidBundle, bundle.MaxBundleBytes) + } + return nil +} + +// validateComponentPath mirrors the bundle path rules for component-supplied +// relative paths, so a hostile tree cannot escape its skills// prefix. +func validateComponentPath(p string) error { + if p == "" { + return fmt.Errorf("%w: empty path", bundle.ErrInvalidBundle) + } + if strings.ContainsRune(p, '\\') { + return fmt.Errorf("%w: backslash in path %q", bundle.ErrInvalidBundle, p) + } + if path.IsAbs(p) { + return fmt.Errorf("%w: absolute path %q", bundle.ErrInvalidBundle, p) + } + if path.Clean(p) != p { + return fmt.Errorf("%w: non-clean path %q", bundle.ErrInvalidBundle, p) + } + if slices.Contains(strings.Split(p, "/"), "..") { + return fmt.Errorf("%w: parent traversal in path %q", bundle.ErrInvalidBundle, p) + } + return nil +} + +// sortedKeys returns m's keys sorted, for deterministic iteration. +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + slices.Sort(keys) + return keys +} diff --git a/internal/registry/plugins/compose/compose_test.go b/internal/registry/plugins/compose/compose_test.go new file mode 100644 index 000000000..0a01f2d7b --- /dev/null +++ b/internal/registry/plugins/compose/compose_test.go @@ -0,0 +1,234 @@ +package compose + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/agentregistry-dev/agentregistry/internal/registry/plugins/bundle" + "github.com/agentregistry-dev/agentregistry/pkg/api/v1alpha1" +) + +func base(files map[string][]byte) *bundle.CanonicalBundle { + return &bundle.CanonicalBundle{Files: files} +} + +func TestCompose_PureComposition_GeneratesManifest(t *testing.T) { + out, report, err := Compose(Inputs{ + PluginName: "incident-response", + Description: "IR toolkit", + Skills: []Skill{{Name: "log-triage", Files: map[string][]byte{"SKILL.md": []byte("# triage")}}}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := string(out.Files["skills/log-triage/SKILL.md"]); got != "# triage" { + t.Errorf("skill not placed, got %q", got) + } + var m struct{ Name, Description string } + if err := json.Unmarshal(out.Files[".claude-plugin/plugin.json"], &m); err != nil { + t.Fatalf("generated manifest not valid JSON: %v", err) + } + if m.Name != "incident-response" || m.Description != "IR toolkit" { + t.Errorf("generated manifest = %+v", m) + } + if len(report.Replaced) != 0 { + t.Errorf("nothing should be replaced, got %+v", report.Replaced) + } + if len(report.Placed) != 1 || report.Placed[0].Dest != "skills/log-triage/" { + t.Errorf("placement = %+v", report.Placed) + } +} + +func TestCompose_BaseManifestPassesThrough(t *testing.T) { + manifest := []byte(`{"name":"base-plugin","hooks":"./hooks/hooks.json"}`) + out, _, err := Compose(Inputs{ + Base: base(map[string][]byte{".claude-plugin/plugin.json": manifest}), + PluginName: "ignored-for-manifest", + Skills: []Skill{{Name: "s", Files: map[string][]byte{"SKILL.md": []byte("x")}}}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Equal(out.Files[".claude-plugin/plugin.json"], manifest) { + t.Errorf("base manifest was modified: %s", out.Files[".claude-plugin/plugin.json"]) + } +} + +func TestCompose_SkillOverlayWins_WholeDirectory(t *testing.T) { + b := base(map[string][]byte{ + "skills/log-triage/SKILL.md": []byte("old"), + "skills/log-triage/helper.py": []byte("old helper"), + "skills/other/SKILL.md": []byte("untouched"), + }) + out, report, err := Compose(Inputs{ + Base: b, + Skills: []Skill{{Name: "log-triage", Files: map[string][]byte{"SKILL.md": []byte("new")}}}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := string(out.Files["skills/log-triage/SKILL.md"]); got != "new" { + t.Errorf("overlay did not win, got %q", got) + } + // Whole-directory replace: the base's helper.py must be gone, not interleaved. + if _, ok := out.Files["skills/log-triage/helper.py"]; ok { + t.Error("base helper.py survived — trees were interleaved, not replaced") + } + if got := string(out.Files["skills/other/SKILL.md"]); got != "untouched" { + t.Errorf("unrelated skill touched: %q", got) + } + if len(report.Replaced) != 1 || report.Replaced[0].Files != 2 || report.Replaced[0].Kind != v1alpha1.KindSkill { + t.Errorf("replacement not recorded correctly: %+v", report.Replaced) + } + // Base must not be mutated. + if _, ok := b.Files["skills/log-triage/helper.py"]; !ok { + t.Error("Compose mutated the base bundle") + } +} + +func TestCompose_CommandOverlayWins(t *testing.T) { + out, report, err := Compose(Inputs{ + Base: base(map[string][]byte{"commands/deploy.md": []byte("old")}), + Commands: []Command{{Name: "deploy", Body: "new body"}}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := string(out.Files["commands/deploy.md"]); got != "new body" { + t.Errorf("command overlay did not win: %q", got) + } + if len(report.Replaced) != 1 || report.Replaced[0].Dest != "commands/deploy.md" { + t.Errorf("replacement not recorded: %+v", report.Replaced) + } +} + +func TestCompose_MCPMerge_PreservesAndReplaces(t *testing.T) { + baseDoc := `{"mcpServers":{"existing":{"type":"http","url":"https://a"},"shadowed":{"type":"http","url":"https://old"}},"unrelatedTop":42}` + out, report, err := Compose(Inputs{ + Base: base(map[string][]byte{".mcp.json": []byte(baseDoc)}), + MCPServers: []MCPServer{ + {Name: "shadowed", Entry: json.RawMessage(`{"type":"http","url":"https://new"}`)}, + {Name: "added", Entry: json.RawMessage(`{"type":"sse","url":"https://b"}`)}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var doc struct { + MCPServers map[string]struct { + Type string `json:"type"` + URL string `json:"url"` + } `json:"mcpServers"` + UnrelatedTop int `json:"unrelatedTop"` + } + if err := json.Unmarshal(out.Files[".mcp.json"], &doc); err != nil { + t.Fatalf("merged .mcp.json invalid: %v", err) + } + if doc.UnrelatedTop != 42 { + t.Error("unrelated top-level field dropped") + } + if doc.MCPServers["existing"].URL != "https://a" { + t.Error("unrelated server entry dropped") + } + if doc.MCPServers["shadowed"].URL != "https://new" { + t.Error("overlay entry did not replace same-named base entry") + } + if doc.MCPServers["added"].Type != "sse" { + t.Error("new entry missing") + } + if len(report.Replaced) != 1 || report.Replaced[0].Name != "shadowed" { + t.Errorf("replacement not recorded: %+v", report.Replaced) + } +} + +func TestCompose_MCPMerge_MalformedBaseIsInvalidBundle(t *testing.T) { + _, _, err := Compose(Inputs{ + Base: base(map[string][]byte{".mcp.json": []byte("not json")}), + MCPServers: []MCPServer{{Name: "x", Entry: json.RawMessage(`{}`)}}, + }) + if err == nil || !strings.Contains(err.Error(), ".mcp.json") { + t.Fatalf("expected malformed .mcp.json error, got %v", err) + } +} + +func TestCompose_InstructionsAppendAndCreate(t *testing.T) { + // Append to existing. + out, _, err := Compose(Inputs{ + Base: base(map[string][]byte{"AGENTS.md": []byte("base rules\n")}), + Instructions: &Instructions{Name: "extra", Body: "overlay rules"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := "base rules\n\n---\n\noverlay rules\n" + if got := string(out.Files["AGENTS.md"]); got != want { + t.Errorf("append: got %q want %q", got, want) + } + // Create when absent (pure composition also generates a manifest). + out, _, err = Compose(Inputs{ + PluginName: "p", + Instructions: &Instructions{Name: "only", Body: "solo rules"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := string(out.Files["AGENTS.md"]); got != "solo rules\n" { + t.Errorf("create: got %q", got) + } +} + +func TestCompose_SkillPathTraversalRejected(t *testing.T) { + _, _, err := Compose(Inputs{ + PluginName: "p", + Skills: []Skill{{Name: "evil", Files: map[string][]byte{"../escape": []byte("x")}}}, + }) + if err == nil || !strings.Contains(err.Error(), "traversal") { + t.Fatalf("expected traversal rejection, got %v", err) + } +} + +func TestCompose_CeilingEnforced(t *testing.T) { + big := make(map[string][]byte, bundle.MaxBundleFiles+1) + for i := 0; i <= bundle.MaxBundleFiles; i++ { + big[fmt.Sprintf("f/%d", i)] = []byte("b") + } + _, _, err := Compose(Inputs{PluginName: "p", Skills: []Skill{{Name: "huge", Files: big}}}) + if err == nil || !strings.Contains(err.Error(), "too many files") { + t.Fatalf("expected file-count ceiling, got %v", err) + } +} + +func TestCompose_Deterministic(t *testing.T) { + in := Inputs{ + Base: base(map[string][]byte{ + ".mcp.json": []byte(`{"mcpServers":{"z":{"url":"https://z"},"a":{"url":"https://a"}}}`), + "AGENTS.md": []byte("rules"), + "skills/s/x.md": []byte("keep"), + }), + Skills: []Skill{{Name: "n", Files: map[string][]byte{"SKILL.md": []byte("s"), "a/b.txt": []byte("t")}}}, + MCPServers: []MCPServer{{Name: "m", Entry: json.RawMessage(`{"url":"https://m"}`)}}, + Commands: []Command{{Name: "c", Body: "cmd"}}, + Instructions: &Instructions{Name: "i", Body: "ins"}, + } + first, _, err := Compose(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for range 10 { + again, _, err := Compose(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(again.Files) != len(first.Files) { + t.Fatalf("file count drifted: %d vs %d", len(again.Files), len(first.Files)) + } + for p, b := range first.Files { + if !bytes.Equal(again.Files[p], b) { + t.Fatalf("non-deterministic output at %q", p) + } + } + } +} diff --git a/internal/registry/plugins/compose/mcpentry.go b/internal/registry/plugins/compose/mcpentry.go new file mode 100644 index 000000000..1c524643f --- /dev/null +++ b/internal/registry/plugins/compose/mcpentry.go @@ -0,0 +1,108 @@ +package compose + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/agentregistry-dev/agentregistry/pkg/api/v1alpha1" +) + +// ErrUnsupportedMCPServer marks an MCPServer spec shape with no faithful +// plugin-local .mcp.json representation — a TERMINAL condition for the +// referencing plugin (design: PLUGIN_COMPOSABILITY_SPIKE.md §6). +var ErrUnsupportedMCPServer = errors.New("compose: mcp server has no .mcp.json representation") + +// mcpRemoteEntry is a desktop-harness remote server entry. +type mcpRemoteEntry struct { + Type string `json:"type"` + URL string `json:"url"` + Headers map[string]string `json:"headers,omitempty"` +} + +// mcpStdioEntry is a desktop-harness stdio server entry. +type mcpStdioEntry struct { + Command string `json:"command"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` +} + +// MCPEntryFromSpec maps a registry MCPServer spec to its plugin-local +// .mcp.json entry. Fidelity rules: a Remote maps directly; a Package with an +// npm/pypi origin maps to a stdio entry (explicit Launch verbatim, otherwise +// the origin-type default command); OCI package origins have no faithful +// desktop form and return ErrUnsupportedMCPServer. +func MCPEntryFromSpec(spec *v1alpha1.MCPServerSpec) (json.RawMessage, error) { + if spec == nil { + return nil, fmt.Errorf("%w: nil spec", ErrUnsupportedMCPServer) + } + if spec.Remote != nil { + entry := mcpRemoteEntry{Type: spec.Remote.Type, URL: spec.Remote.URL} + if len(spec.Remote.Headers) > 0 { + entry.Headers = map[string]string{} + for _, h := range spec.Remote.Headers { + entry.Headers[h.Name] = h.Value + } + } + return json.Marshal(entry) + } + if spec.Source == nil || spec.Source.Package == nil { + return nil, fmt.Errorf("%w: neither remote nor package declared", ErrUnsupportedMCPServer) + } + pkg := spec.Source.Package + entry := mcpStdioEntry{} + switch pkg.Origin.Type { + case v1alpha1.MCPPackageOriginTypeNPM: + entry.Command, entry.Args = "npx", []string{"-y", pinnedIdentifier(pkg.Origin.Identifier, "@", pkg.Origin.NPM.Version)} + case v1alpha1.MCPPackageOriginTypePyPI: + entry.Command, entry.Args = "uvx", []string{pinnedIdentifier(pkg.Origin.Identifier, "==", pkg.Origin.PyPI.Version)} + case v1alpha1.MCPPackageOriginTypeOCI: + return nil, fmt.Errorf("%w: oci package origin cannot run as a plugin-local stdio server", ErrUnsupportedMCPServer) + default: + return nil, fmt.Errorf("%w: unknown package origin type %q", ErrUnsupportedMCPServer, pkg.Origin.Type) + } + // An explicit Launch owns command/args verbatim (mirrors the deploy-time + // resolver contract on MCPPackageLaunch). + if l := pkg.Launch; l != nil { + if l.Command != "" { + entry.Command = l.Command + entry.Args = launchArgs(l.Args) + } + if len(l.Env) > 0 { + entry.Env = map[string]string{} + for _, e := range l.Env { + entry.Env[e.Name] = e.Value + } + } + } + return json.Marshal(entry) +} + +// pinnedIdentifier joins identifier and version with sep when a version is +// set, e.g. "@scope/pkg@1.2.3" or "pkg==1.2.3". +func pinnedIdentifier(identifier, sep, version string) string { + if version == "" { + return identifier + } + return identifier + sep + version +} + +// launchArgs flattens MCPArguments in the same positional-then-named order +// the Kubernetes materializer uses. +func launchArgs(args []v1alpha1.MCPArgument) []string { + var out []string + for _, a := range args { + if a.Type == v1alpha1.MCPArgumentTypePositional && a.Value != "" { + out = append(out, a.Value) + } + } + for _, a := range args { + if a.Type == v1alpha1.MCPArgumentTypeNamed { + out = append(out, a.Name) + if a.Value != "" { + out = append(out, a.Value) + } + } + } + return out +} diff --git a/internal/registry/plugins/source/source.go b/internal/registry/plugins/source/source.go index d2ae4a3e9..841ce0972 100644 --- a/internal/registry/plugins/source/source.go +++ b/internal/registry/plugins/source/source.go @@ -105,6 +105,29 @@ func (r *GitResolver) resolveGit(ctx context.Context, g *v1alpha1.PluginSourceGi return &v1alpha1.PluginResolvedSource{Type: v1alpha1.PluginSourceTypeGit, Commit: commit}, b, nil } +// FetchGitTree loads the file tree of repo pinned at commit — the +// component-fetch path for composed plugins (a referenced Skill's repo at the +// commit its own controller pinned). Same host restrictions, clone bound, and +// bundle ceilings as a plugin base resolve. +func FetchGitTree(ctx context.Context, repo *v1alpha1.Repository, commit string) (*bundle.CanonicalBundle, error) { + if repo == nil || repo.URL == "" { + return nil, fmt.Errorf("%w: git source missing repository url", ErrUnsupportedSource) + } + ctx, cancel := context.WithTimeout(ctx, cloneTimeout) + defer cancel() + + dir, err := os.MkdirTemp("", "arctl-plugin-comp-*") + if err != nil { + return nil, err + } + defer func() { _ = os.RemoveAll(dir) }() + + if err := gitutil.CloneAndCopyContext(ctx, repo.URL, "", commit, repo.Subfolder, dir, false); err != nil { + return nil, classifyGitErr(err, "clone component source") + } + return bundle.FromDir(dir) +} + // classifyGitErr maps a gitutil error to the resolver's terminal/retryable // contract: a non-github host or a missing ref is terminal (wrapped in a // terminal sentinel); anything else (network, transport) is retryable. diff --git a/openapi.yaml b/openapi.yaml index 6c7903891..4ece1f705 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -142,6 +142,18 @@ components: - Paths - Map type: object + ComponentRef: + additionalProperties: false + properties: + name: + type: string + namespace: + type: string + tag: + type: string + required: + - name + type: object Condition: additionalProperties: false properties: @@ -1229,6 +1241,27 @@ components: required: - name type: object + PluginResolvedComponent: + additionalProperties: false + properties: + commit: + type: string + contentHash: + type: string + kind: + type: string + name: + type: string + namespace: + type: string + tag: + type: string + required: + - kind + - namespace + - name + - tag + type: object PluginResolvedSource: additionalProperties: false properties: @@ -1282,6 +1315,12 @@ components: PluginSpec: additionalProperties: false properties: + commands: + items: + $ref: '#/components/schemas/ComponentRef' + type: + - array + - "null" description: type: string harnesses: @@ -1292,6 +1331,20 @@ components: - "null" iconUrl: type: string + instructions: + $ref: '#/components/schemas/ComponentRef' + mcpServers: + items: + $ref: '#/components/schemas/ComponentRef' + type: + - array + - "null" + skills: + items: + $ref: '#/components/schemas/ComponentRef' + type: + - array + - "null" source: $ref: '#/components/schemas/PluginSource' title: @@ -1311,6 +1364,12 @@ components: $ref: '#/components/schemas/PluginInventory' manifest: $ref: '#/components/schemas/PluginManifest' + resolvedComponents: + items: + $ref: '#/components/schemas/PluginResolvedComponent' + type: + - array + - "null" resolvedSource: $ref: '#/components/schemas/PluginResolvedSource' type: object diff --git a/pkg/api/v1alpha1/accessors.go b/pkg/api/v1alpha1/accessors.go index 353b703d0..2b4cc0bcf 100644 --- a/pkg/api/v1alpha1/accessors.go +++ b/pkg/api/v1alpha1/accessors.go @@ -203,6 +203,11 @@ func (p *Plugin) MarshalStatus() (json.RawMessage, error) { return nil, err } } + if len(p.Status.ResolvedComponents) > 0 { + if m["resolvedComponents"], err = json.Marshal(p.Status.ResolvedComponents); err != nil { + return nil, err + } + } if p.Status.Manifest != nil { if m["manifest"], err = json.Marshal(p.Status.Manifest); err != nil { return nil, err @@ -225,14 +230,16 @@ func (p *Plugin) UnmarshalStatus(data json.RawMessage) error { return err } var custom struct { - ResolvedSource *PluginResolvedSource `json:"resolvedSource"` - Manifest *PluginManifest `json:"manifest"` - Inventory *PluginInventory `json:"inventory"` + ResolvedSource *PluginResolvedSource `json:"resolvedSource"` + ResolvedComponents []PluginResolvedComponent `json:"resolvedComponents"` + Manifest *PluginManifest `json:"manifest"` + Inventory *PluginInventory `json:"inventory"` } if err := json.Unmarshal(data, &custom); err != nil { return err } p.Status.ResolvedSource, p.Status.Manifest, p.Status.Inventory = custom.ResolvedSource, custom.Manifest, custom.Inventory + p.Status.ResolvedComponents = custom.ResolvedComponents return nil } diff --git a/pkg/api/v1alpha1/plugin.go b/pkg/api/v1alpha1/plugin.go index 06071bbc8..d9dbf02b8 100644 --- a/pkg/api/v1alpha1/plugin.go +++ b/pkg/api/v1alpha1/plugin.go @@ -40,9 +40,27 @@ type PluginSpec struct { // deploy-time adapters decide which harnesses they can consume. Harnesses []string `json:"harnesses,omitempty" yaml:"harnesses,omitempty"` - // Source is where the bundle is ingested from, pinned (git commit / OCI - // digest) so a published tag is reproducible. + // Source is the optional base layer of the bundle, pinned (git commit / + // OCI digest) so a published tag is reproducible. A plugin may instead be + // a pure composition of registry artifacts (no base source); at least one + // of Source or the composition fields below must be set. Source *PluginSource `json:"source,omitempty" yaml:"source,omitempty"` + + // Composition: registry artifacts overlaid onto the base at compile time. + // Each field fixes its refs' kind, so ComponentRef carries no Kind. The + // controller resolves every ref to a concrete pin recorded in + // status.ResolvedComponents; materialization reads only those pins. + // + // Skills overlay Skill repos at skills// (whole-directory + // overlay-wins over same-named base content). + Skills []ComponentRef `json:"skills,omitempty" yaml:"skills,omitempty"` + // MCPServers merge into the bundle's .mcp.json keyed by server name + // (overlay entry replaces a same-named base entry). + MCPServers []ComponentRef `json:"mcpServers,omitempty" yaml:"mcpServers,omitempty"` + // Commands are Prompt refs materialized at commands/.md. + Commands []ComponentRef `json:"commands,omitempty" yaml:"commands,omitempty"` + // Instructions is a Prompt ref appended to the bundle's AGENTS.md. + Instructions *ComponentRef `json:"instructions,omitempty" yaml:"instructions,omitempty"` } // PluginStatus is the Plugin observed-state subresource, written by the Plugin @@ -60,8 +78,14 @@ type PluginStatus struct { Status `json:",inline" yaml:",inline"` // ResolvedSource is the controller's immutable pin of the user's source - // pointer (the concrete commit/digest the source resolved to). + // pointer (the concrete commit/digest the source resolved to). Nil when + // the plugin has no base source (pure composition). ResolvedSource *PluginResolvedSource `json:"resolvedSource,omitempty" yaml:"resolvedSource,omitempty"` + // ResolvedComponents is the controller's pin of every composition ref, in + // spec order (skills, mcpServers, commands, instructions). The pin set + // freezes until the spec changes; compilation is a pure function of it. + // Entries carry Kind because this is a flattened cross-kind list. + ResolvedComponents []PluginResolvedComponent `json:"resolvedComponents,omitempty" yaml:"resolvedComponents,omitempty"` // Manifest is the canonical typed plugin.json parsed from the source. Manifest *PluginManifest `json:"manifest,omitempty" yaml:"manifest,omitempty"` // Inventory is the server-derived risk surface / search index. @@ -80,6 +104,21 @@ type PluginResolvedSource struct { Digest string `json:"digest,omitempty" yaml:"digest,omitempty"` } +// PluginResolvedComponent records the concrete pin one composition ref +// resolved to. Commit is set for source-backed kinds (Skill); ContentHash +// (sha256 of the canonical spec JSON) for inline content kinds (Prompt, +// MCPServer). Tag is the tag actually resolved, never blank. +type PluginResolvedComponent struct { + Kind string `json:"kind" yaml:"kind"` + Namespace string `json:"namespace" yaml:"namespace"` + Name string `json:"name" yaml:"name"` + Tag string `json:"tag" yaml:"tag"` + // Commit is the resolved full git commit SHA (source-backed kinds). + Commit string `json:"commit,omitempty" yaml:"commit,omitempty"` + // ContentHash pins inline content kinds: sha256 of the canonical spec JSON. + ContentHash string `json:"contentHash,omitempty" yaml:"contentHash,omitempty"` +} + // PluginSourceType selects which source sub-struct is set. type PluginSourceType string diff --git a/pkg/api/v1alpha1/plugin_validate.go b/pkg/api/v1alpha1/plugin_validate.go index ffb9e9387..a5fa66e9f 100644 --- a/pkg/api/v1alpha1/plugin_validate.go +++ b/pkg/api/v1alpha1/plugin_validate.go @@ -1,6 +1,7 @@ package v1alpha1 import ( + "context" "fmt" "strings" ) @@ -15,19 +16,91 @@ func (p *Plugin) Validate() error { return errs } +// ResolveRefs checks every composition ref in the Plugin's spec exists by +// calling resolver. ComponentRef carries no Kind — each field supplies the +// kind it implies — so there is no defaulting or mismatch surface here. +func (p *Plugin) ResolveRefs(ctx context.Context, resolver ResolverFunc) error { + if resolver == nil { + return nil + } + var errs FieldErrors + ns := p.Metadata.Namespace + errs = append(errs, resolveComponentRefs(ctx, resolver, ns, "spec.skills", p.Spec.Skills, KindSkill)...) + errs = append(errs, resolveComponentRefs(ctx, resolver, ns, "spec.mcpServers", p.Spec.MCPServers, KindMCPServer)...) + errs = append(errs, resolveComponentRefs(ctx, resolver, ns, "spec.commands", p.Spec.Commands, KindPrompt)...) + if p.Spec.Instructions != nil { + errs = append(errs, resolveComponentRefs(ctx, resolver, ns, "spec.instructions", []ComponentRef{*p.Spec.Instructions}, KindPrompt)...) + } + if len(errs) == 0 { + return nil + } + return errs +} + +// resolveComponentRefs resolves a slice of component refs against the kind +// the holding field implies. +func resolveComponentRefs(ctx context.Context, resolver ResolverFunc, ns, path string, refs []ComponentRef, kind string) FieldErrors { + var errs FieldErrors + for i, ref := range refs { + errs = append(errs, resolveRefWith(ctx, resolver, ref.AsResourceRef(kind, ns), fmt.Sprintf("%s[%d]", path, i))...) + } + return errs +} + func validatePluginSpec(s *PluginSpec) FieldErrors { var errs FieldErrors errs.Append("spec.title", validateTitle(s.Title)) errs.Append("spec.iconUrl", validateIconURL(s.IconURL)) - // Source is required: it is the pointer the controller resolves and pins. - if s.Source == nil { - errs.Append("spec.source", fmt.Errorf("%w", ErrRequiredField)) - } else { + // A plugin is a base source, a composition of registry artifacts, or both. + if s.Source == nil && !s.hasComposition() { + errs.Append("spec", fmt.Errorf("%w: set source and/or composition fields (skills/mcpServers/commands/instructions)", ErrRequiredField)) + } + if s.Source != nil { for _, e := range validatePluginSource(s.Source) { errs.Append("spec.source."+e.Path, e.Cause) } } + + // Materialized paths are keyed by name (skills//, commands/.md), + // so duplicate names within one field have no defined precedence — reject. + // Overlay-vs-base collisions are legal (overlay wins) and handled at compose. + errs = append(errs, validateComponentRefs("spec.skills", s.Skills, KindSkill, true)...) + errs = append(errs, validateComponentRefs("spec.mcpServers", s.MCPServers, KindMCPServer, true)...) + errs = append(errs, validateComponentRefs("spec.commands", s.Commands, KindPrompt, true)...) + if s.Instructions != nil { + errs = append(errs, validateComponentRefs("spec.instructions", []ComponentRef{*s.Instructions}, KindPrompt, false)...) + } + return errs +} + +// hasComposition reports whether any composition field is set. +func (s *PluginSpec) hasComposition() bool { + return len(s.Skills) > 0 || len(s.MCPServers) > 0 || len(s.Commands) > 0 || s.Instructions != nil +} + +// validateComponentRefs runs structural checks on component refs: name/ +// namespace/tag formats (via validateRef with the field's implied kind) and, +// when rejectDuplicates is set, uniqueness of (namespace-defaulted) names. +func validateComponentRefs(path string, refs []ComponentRef, kind string, rejectDuplicates bool) FieldErrors { + var errs FieldErrors + seen := map[string]struct{}{} + for i, ref := range refs { + for _, e := range validateRef(ref.AsResourceRef(kind, "")) { + // Kind is machine-supplied here; a kind error would be a + // programming bug, but the path stays honest either way. + errs.Append(fmt.Sprintf("%s[%d].%s", path, i, e.Path), e.Cause) + } + if !rejectDuplicates { + continue + } + if _, ok := seen[ref.Name]; ok { + errs.Append(fmt.Sprintf("%s[%d].name", path, i), + fmt.Errorf("%w: duplicate name %q (materialized path is keyed by name)", ErrInvalidFormat, ref.Name)) + continue + } + seen[ref.Name] = struct{}{} + } return errs } diff --git a/pkg/api/v1alpha1/plugin_validate_test.go b/pkg/api/v1alpha1/plugin_validate_test.go index 96937053a..536ca4be8 100644 --- a/pkg/api/v1alpha1/plugin_validate_test.go +++ b/pkg/api/v1alpha1/plugin_validate_test.go @@ -56,9 +56,39 @@ func TestPluginValidate(t *testing.T) { spec: PluginSpec{Source: &PluginSource{Type: PluginSourceTypeOCI, OCI: &PluginSourceOCI{Reference: "ghcr.io/org/plugin@sha256:" + strings.Repeat("a", 64)}}}, }, { - name: "missing source", + name: "missing source and composition", spec: PluginSpec{Title: "x"}, - wantErr: "spec.source", + wantErr: "set source and/or composition", + }, + { + name: "pure composition without source is valid", + spec: PluginSpec{Skills: []ComponentRef{{Name: "log-triage", Tag: "v1"}}}, + }, + { + name: "composition ref requires name", + spec: PluginSpec{Skills: []ComponentRef{{Tag: "v1"}}}, + wantErr: "spec.skills[0].name", + }, + { + name: "duplicate skill names rejected", + spec: PluginSpec{Skills: []ComponentRef{{Name: "a"}, {Name: "a", Tag: "v2"}}}, + wantErr: "spec.skills[1].name", + }, + { + name: "duplicate command names rejected", + spec: PluginSpec{Commands: []ComponentRef{{Name: "c"}, {Name: "c"}}}, + wantErr: "spec.commands[1].name", + }, + { + name: "instructions-only composition is valid", + spec: PluginSpec{Instructions: &ComponentRef{Name: "guidelines"}}, + }, + { + name: "source plus composition is valid", + spec: PluginSpec{ + Source: &PluginSource{Type: PluginSourceTypeGit, Git: &PluginSourceGit{Repository: &Repository{URL: "https://github.com/org/repo"}}}, + Skills: []ComponentRef{{Name: "log-triage"}}, + }, }, { name: "git source with branch only (controller resolves the commit)", diff --git a/pkg/api/v1alpha1/ref.go b/pkg/api/v1alpha1/ref.go index 7f2d96601..f427c200a 100644 --- a/pkg/api/v1alpha1/ref.go +++ b/pkg/api/v1alpha1/ref.go @@ -15,6 +15,30 @@ type ResourceRef struct { Tag string `json:"tag,omitempty" yaml:"tag,omitempty"` } +// ComponentRef is a reference to a registry artifact whose kind is fixed by +// the field holding it (e.g. PluginSpec.Skills refs are always Kind=Skill). +// Unlike ResourceRef it carries no Kind: the schema already determines it, so +// a kind field would be pure redundancy plus a defaulting/mismatch surface. +// +// Namespace is optional: blank means "same namespace as the referencing +// object". Tag is optional: blank means "resolve to the literal latest tag". +type ComponentRef struct { + Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"` + Name string `json:"name" yaml:"name"` + Tag string `json:"tag,omitempty" yaml:"tag,omitempty"` +} + +// AsResourceRef adapts the ref for machinery that speaks ResourceRef, +// supplying the kind the holding field implies and defaulting a blank +// namespace to fallbackNamespace. +func (r ComponentRef) AsResourceRef(kind, fallbackNamespace string) ResourceRef { + ns := r.Namespace + if ns == "" { + ns = fallbackNamespace + } + return ResourceRef{Kind: kind, Namespace: ns, Name: r.Name, Tag: r.Tag} +} + // DeploymentRef is a typed reference to another Deployment resource. Kind // is implicit (always Deployment) and Tag is omitted because Deployment is // a mutable-object kind keyed by namespace/name. diff --git a/pkg/api/v1alpha1/validation_test.go b/pkg/api/v1alpha1/validation_test.go index 770f3707e..6b13e8317 100644 --- a/pkg/api/v1alpha1/validation_test.go +++ b/pkg/api/v1alpha1/validation_test.go @@ -1278,3 +1278,50 @@ func TestPromptValidate_IconURL(t *testing.T) { }) } } + +func TestPluginResolveRefs_SuppliesKindPerField(t *testing.T) { + var seen []ResourceRef + resolver := func(ctx context.Context, ref ResourceRef) error { + seen = append(seen, ref) + return nil + } + p := &Plugin{ + Metadata: ObjectMeta{Namespace: "team-a", Name: "p", Tag: "v1"}, + Spec: PluginSpec{ + Skills: []ComponentRef{{Name: "log-triage", Tag: "v3"}}, + MCPServers: []ComponentRef{{Namespace: "shared", Name: "pagerduty"}}, + Commands: []ComponentRef{{Name: "declare-incident"}}, + Instructions: &ComponentRef{Name: "guidelines"}, + }, + } + require.NoError(t, p.ResolveRefs(context.Background(), resolver)) + require.Len(t, seen, 4) + // The holding field supplies the kind; blank namespaces inherit the plugin's. + require.Equal(t, ResourceRef{Kind: KindSkill, Namespace: "team-a", Name: "log-triage", Tag: "v3"}, seen[0]) + require.Equal(t, ResourceRef{Kind: KindMCPServer, Namespace: "shared", Name: "pagerduty"}, seen[1]) + require.Equal(t, ResourceRef{Kind: KindPrompt, Namespace: "team-a", Name: "declare-incident"}, seen[2]) + require.Equal(t, ResourceRef{Kind: KindPrompt, Namespace: "team-a", Name: "guidelines"}, seen[3]) +} + +func TestPluginResolveRefs_ReportsDangling(t *testing.T) { + resolver := func(ctx context.Context, ref ResourceRef) error { + if ref.Name == "missing" { + return ErrDanglingRef + } + return nil + } + p := &Plugin{ + Metadata: ObjectMeta{Namespace: "default", Name: "p", Tag: "v1"}, + Spec: PluginSpec{ + Skills: []ComponentRef{{Name: "ok"}, {Name: "missing"}}, + }, + } + err := p.ResolveRefs(context.Background(), resolver) + require.Error(t, err) + require.Contains(t, err.Error(), "spec.skills[1]") +} + +func TestPluginResolveRefs_NilResolverIsNoOp(t *testing.T) { + p := &Plugin{Metadata: ObjectMeta{Namespace: "default", Name: "p"}} + require.NoError(t, p.ResolveRefs(context.Background(), nil)) +} diff --git a/pkg/pluginmarketplace/translate.go b/pkg/pluginmarketplace/translate.go index 97d9d2ae7..387352d1c 100644 --- a/pkg/pluginmarketplace/translate.go +++ b/pkg/pluginmarketplace/translate.go @@ -14,6 +14,13 @@ var ( // ErrUnsupportedSource is returned for a Plugin whose resolved source type // has no marketplace.json representation (OCI). ErrUnsupportedSource = errors.New("resolved source type has no marketplace.json representation") + // ErrComposed is returned for a Plugin with composition refs. A composed + // plugin has no single upstream git URL — serving just its base source + // would silently drop the overlays — so it is skipped until the registry + // can serve compiled bundles (git-backed marketplace, + // agentregistry-enterprise#1195). Consume composed plugins via + // deploy-time materialization or arctl pull instead. + ErrComposed = errors.New("composed plugin has no single-source marketplace.json representation") ) // nameSep joins namespace and name into a marketplace.json-safe qualified @@ -44,6 +51,9 @@ func qualifiedName(namespace, name string) string { // marketplace.json schema has no OCI/image source form) — callers must skip // these, never emit a partial/broken entry. func FromPlugin(p *v1alpha1.Plugin) (PluginEntry, error) { + if len(p.Spec.Skills) > 0 || len(p.Spec.MCPServers) > 0 || len(p.Spec.Commands) > 0 || p.Spec.Instructions != nil { + return PluginEntry{}, ErrComposed + } if !p.Status.IsConditionTrue("Ready") || p.Status.ResolvedSource == nil { return PluginEntry{}, ErrNotResolved } diff --git a/pkg/pluginmarketplace/translate_test.go b/pkg/pluginmarketplace/translate_test.go index c2a9f8980..550e28f2a 100644 --- a/pkg/pluginmarketplace/translate_test.go +++ b/pkg/pluginmarketplace/translate_test.go @@ -1,6 +1,7 @@ package pluginmarketplace_test import ( + "errors" "testing" "github.com/stretchr/testify/assert" @@ -220,3 +221,34 @@ func TestFromPlugin_ManifestDescriptionOverridesSpec(t *testing.T) { assert.Equal(t, "manifest-level description", got.Description) assert.Equal(t, "2.0.0", got.Version) } + +// TestFromPlugin_ComposedIsSkipped guards the v1 serving decision: a composed +// plugin (composition refs present) has no single upstream URL, so it must be +// skipped even when its BASE source is resolved — serving the base alone +// would silently drop the overlays. +func TestFromPlugin_ComposedIsSkipped(t *testing.T) { + p := &v1alpha1.Plugin{ + Metadata: v1alpha1.ObjectMeta{Namespace: "default", Name: "composed", Tag: "v1", Generation: 1}, + Spec: v1alpha1.PluginSpec{ + Source: &v1alpha1.PluginSource{ + Type: v1alpha1.PluginSourceTypeGit, + Git: &v1alpha1.PluginSourceGit{Repository: &v1alpha1.Repository{URL: "https://github.com/o/base"}}, + }, + Skills: []v1alpha1.ComponentRef{{Name: "curated"}}, + }, + } + p.Status.ObservedGeneration = 1 + p.Status.ResolvedSource = &v1alpha1.PluginResolvedSource{Type: v1alpha1.PluginSourceTypeGit, Commit: "abc"} + p.Status.SetCondition(v1alpha1.Condition{Type: "Ready", Status: v1alpha1.ConditionTrue}) + + if _, err := pluginmarketplace.FromPlugin(p); !errors.Is(err, pluginmarketplace.ErrComposed) { + t.Fatalf("expected pluginmarketplace.ErrComposed, got %v", err) + } + + // Pure composition (no base) is also skipped, via the same gate. + p.Spec.Source = nil + p.Status.ResolvedSource = nil + if _, err := pluginmarketplace.FromPlugin(p); !errors.Is(err, pluginmarketplace.ErrComposed) { + t.Fatalf("expected pluginmarketplace.ErrComposed for pure composition, got %v", err) + } +} diff --git a/pkg/types/fingerprint.go b/pkg/types/fingerprint.go index 33ca63d3e..497f08cb3 100644 --- a/pkg/types/fingerprint.go +++ b/pkg/types/fingerprint.go @@ -192,12 +192,16 @@ func dependencyMaterial(kind string, obj v1alpha1.Object) (json.RawMessage, erro switch kind { case v1alpha1.KindPlugin: plugin, ok := obj.(*v1alpha1.Plugin) - if !ok || plugin.Status.ResolvedSource == nil { + // A composed plugin's material is its full pin set: the base source pin + // (nil for pure composition) plus every component pin. Deployments must + // redeploy when either moves. + if !ok || (plugin.Status.ResolvedSource == nil && len(plugin.Status.ResolvedComponents) == 0) { return nil, nil } return json.Marshal(struct { - ResolvedSource *v1alpha1.PluginResolvedSource `json:"resolvedSource"` - }{ResolvedSource: plugin.Status.ResolvedSource}) + ResolvedSource *v1alpha1.PluginResolvedSource `json:"resolvedSource,omitempty"` + ResolvedComponents []v1alpha1.PluginResolvedComponent `json:"resolvedComponents,omitempty"` + }{ResolvedSource: plugin.Status.ResolvedSource, ResolvedComponents: plugin.Status.ResolvedComponents}) case v1alpha1.KindSkill: skill, ok := obj.(*v1alpha1.Skill) if !ok || skill.Status.ResolvedSource == nil { diff --git a/pkg/types/fingerprint_test.go b/pkg/types/fingerprint_test.go index 515f18398..5ab5a01d2 100644 --- a/pkg/types/fingerprint_test.go +++ b/pkg/types/fingerprint_test.go @@ -400,3 +400,53 @@ func testMCPServerInNamespace(namespace, name, identifier string) *v1alpha1.MCPS }, } } + +// TestPluginDependencyMaterial_IncludesComponentPins guards the composability +// contract: a deployment's change-detection material must move when a composed +// plugin's pin set moves, and a pure-composition plugin (no base source) must +// still produce material. +func TestPluginDependencyMaterial_IncludesComponentPins(t *testing.T) { + pin := func(commit string) []v1alpha1.PluginResolvedComponent { + return []v1alpha1.PluginResolvedComponent{{ + Kind: v1alpha1.KindSkill, Namespace: "default", Name: "s", Tag: "v1", Commit: commit, + }} + } + + base := testPlugin("default", "p", "aaaa") + base.Status.ResolvedComponents = pin("c1") + moved := testPlugin("default", "p", "aaaa") + moved.Status.ResolvedComponents = pin("c2") + + m1, err := dependencyMaterial(v1alpha1.KindPlugin, base) + if err != nil { + t.Fatalf("dependencyMaterial: %v", err) + } + m2, err := dependencyMaterial(v1alpha1.KindPlugin, moved) + if err != nil { + t.Fatalf("dependencyMaterial: %v", err) + } + if materialHash(m1) == materialHash(m2) { + t.Error("component pin change must change the material hash") + } + + // Pure composition: no base source pin, components only. + pure := testPlugin("default", "p", "aaaa") + pure.Status.ResolvedSource = nil + pure.Status.ResolvedComponents = pin("c1") + m3, err := dependencyMaterial(v1alpha1.KindPlugin, pure) + if err != nil { + t.Fatalf("dependencyMaterial: %v", err) + } + if len(m3) == 0 { + t.Error("pure-composition plugin must still produce material") + } + + // Unchanged pin set is stable (no spurious redeploys). + again, err := dependencyMaterial(v1alpha1.KindPlugin, base) + if err != nil { + t.Fatalf("dependencyMaterial: %v", err) + } + if materialHash(m1) != materialHash(again) { + t.Error("unchanged pin set must produce a stable material hash") + } +} diff --git a/ui/lib/api/index.ts b/ui/lib/api/index.ts index 0c0d92d4c..e81952035 100644 --- a/ui/lib/api/index.ts +++ b/ui/lib/api/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts export { applyBatch, applyDeployment, applyRuntime, deleteAgent, deleteBatch, deleteDeployment, deleteMcpserver, deleteModel, deletePlugin, deletePrompt, deleteRuntime, deleteSkill, getAgent, getHealthV0, getLatestAgent, getLatestDeployment, getLatestMcpserver, getLatestModel, getLatestPlugin, getLatestPrompt, getLatestRuntime, getLatestSkill, getMcpserver, getModel, getPlugin, getPrompt, getSkill, getVersionV0, listAgents, listDeployments, listMcpservers, listModels, listPlugins, listPrompts, listRuntimes, listSkills, listTagsAgent, listTagsMcpserver, listTagsModel, listTagsPlugin, listTagsPrompt, listTagsSkill, mcpRegistryGetServerVersion, mcpRegistryListServers, mcpRegistryListServerVersions, type Options, pingV0, pluginMarketplaceGet } from './sdk.gen'; -export type { Agent, AgentSource, AgentSpec, ApplyBatchData, ApplyBatchError, ApplyBatchErrors, ApplyBatchResponse, ApplyBatchResponses, ApplyDeploymentData, ApplyDeploymentError, ApplyDeploymentErrors, ApplyDeploymentResponse, ApplyDeploymentResponses, ApplyResult, ApplyResultsResponse, ApplyRuntimeData, ApplyRuntimeError, ApplyRuntimeErrors, ApplyRuntimeResponse, ApplyRuntimeResponses, ClientOptions, CommandEntry, CommandsField, Condition, DeleteAgentData, DeleteAgentError, DeleteAgentErrors, DeleteAgentResponse, DeleteAgentResponses, DeleteBatchData, DeleteBatchError, DeleteBatchErrors, DeleteBatchResponse, DeleteBatchResponses, DeleteDeploymentData, DeleteDeploymentError, DeleteDeploymentErrors, DeleteDeploymentResponse, DeleteDeploymentResponses, DeleteMcpserverData, DeleteMcpserverError, DeleteMcpserverErrors, DeleteMcpserverResponse, DeleteMcpserverResponses, DeleteModelData, DeleteModelError, DeleteModelErrors, DeleteModelResponse, DeleteModelResponses, DeletePluginData, DeletePluginError, DeletePluginErrors, DeletePluginResponse, DeletePluginResponses, DeletePromptData, DeletePromptError, DeletePromptErrors, DeletePromptResponse, DeletePromptResponses, DeleteRuntimeData, DeleteRuntimeError, DeleteRuntimeErrors, DeleteRuntimeResponse, DeleteRuntimeResponses, DeleteSkillData, DeleteSkillError, DeleteSkillErrors, DeleteSkillResponse, DeleteSkillResponses, Deployment, DeploymentHarness, DeploymentRef, DeploymentSpec, EnvFromSource, ErrorDetail, ErrorModel, GetAgentData, GetAgentError, GetAgentErrors, GetAgentResponse, GetAgentResponses, GetHealthV0Data, GetHealthV0Error, GetHealthV0Errors, GetHealthV0Response, GetHealthV0Responses, GetLatestAgentData, GetLatestAgentError, GetLatestAgentErrors, GetLatestAgentResponse, GetLatestAgentResponses, GetLatestDeploymentData, GetLatestDeploymentError, GetLatestDeploymentErrors, GetLatestDeploymentResponse, GetLatestDeploymentResponses, GetLatestMcpserverData, GetLatestMcpserverError, GetLatestMcpserverErrors, GetLatestMcpserverResponse, GetLatestMcpserverResponses, GetLatestModelData, GetLatestModelError, GetLatestModelErrors, GetLatestModelResponse, GetLatestModelResponses, GetLatestPluginData, GetLatestPluginError, GetLatestPluginErrors, GetLatestPluginResponse, GetLatestPluginResponses, GetLatestPromptData, GetLatestPromptError, GetLatestPromptErrors, GetLatestPromptResponse, GetLatestPromptResponses, GetLatestRuntimeData, GetLatestRuntimeError, GetLatestRuntimeErrors, GetLatestRuntimeResponse, GetLatestRuntimeResponses, GetLatestSkillData, GetLatestSkillError, GetLatestSkillErrors, GetLatestSkillResponse, GetLatestSkillResponses, GetMcpserverData, GetMcpserverError, GetMcpserverErrors, GetMcpserverResponse, GetMcpserverResponses, GetModelData, GetModelError, GetModelErrors, GetModelResponse, GetModelResponses, GetPluginData, GetPluginError, GetPluginErrors, GetPluginResponse, GetPluginResponses, GetPromptData, GetPromptError, GetPromptErrors, GetPromptResponse, GetPromptResponses, GetSkillData, GetSkillError, GetSkillErrors, GetSkillResponse, GetSkillResponses, GetVersionV0Data, GetVersionV0Error, GetVersionV0Errors, GetVersionV0Response, GetVersionV0Responses, HarnessCompatibility, HealthBody, HookEntry, HookMatcherGroup, HooksField, HttpHeader, ListAgentsData, ListAgentsError, ListAgentsErrors, ListAgentsResponse, ListAgentsResponses, ListDeploymentsData, ListDeploymentsError, ListDeploymentsErrors, ListDeploymentsResponse, ListDeploymentsResponses, ListMcpserversData, ListMcpserversError, ListMcpserversErrors, ListMcpserversResponse, ListMcpserversResponses, ListMetadata, ListModelsData, ListModelsError, ListModelsErrors, ListModelsResponse, ListModelsResponses, ListOutputAgentBody, ListOutputDeploymentBody, ListOutputMcpServerBody, ListOutputModelBody, ListOutputPluginBody, ListOutputPromptBody, ListOutputRuntimeBody, ListOutputSkillBody, ListPluginsData, ListPluginsError, ListPluginsErrors, ListPluginsResponse, ListPluginsResponses, ListPromptsData, ListPromptsError, ListPromptsErrors, ListPromptsResponse, ListPromptsResponses, ListRuntimesData, ListRuntimesError, ListRuntimesErrors, ListRuntimesResponse, ListRuntimesResponses, ListSkillsData, ListSkillsError, ListSkillsErrors, ListSkillsResponse, ListSkillsResponses, ListTagsAgentData, ListTagsAgentError, ListTagsAgentErrors, ListTagsAgentResponse, ListTagsAgentResponses, ListTagsMcpserverData, ListTagsMcpserverError, ListTagsMcpserverErrors, ListTagsMcpserverResponse, ListTagsMcpserverResponses, ListTagsModelData, ListTagsModelError, ListTagsModelErrors, ListTagsModelResponse, ListTagsModelResponses, ListTagsPluginData, ListTagsPluginError, ListTagsPluginErrors, ListTagsPluginResponse, ListTagsPluginResponses, ListTagsPromptData, ListTagsPromptError, ListTagsPromptErrors, ListTagsPromptResponse, ListTagsPromptResponses, ListTagsSkillData, ListTagsSkillError, ListTagsSkillErrors, ListTagsSkillResponse, ListTagsSkillResponses, LspServerEntry, LspServersField, MarketplaceResponse, McpArgument, McpKeyValueInput, McpPackage, McpPackageLaunch, McpPackageOrigin, McpPackageOriginNpm, McpPackageOriginOci, McpPackageOriginPyPi, McpRegistryGetServerVersionData, McpRegistryGetServerVersionError, McpRegistryGetServerVersionErrors, McpRegistryGetServerVersionResponse, McpRegistryGetServerVersionResponses, McpRegistryListServersData, McpRegistryListServersError, McpRegistryListServersErrors, McpRegistryListServersResponse, McpRegistryListServersResponses, McpRegistryListServerVersionsData, McpRegistryListServerVersionsError, McpRegistryListServerVersionsErrors, McpRegistryListServerVersionsResponse, McpRegistryListServerVersionsResponses, McpRemote, McpServer, McpServerEntry, McpServerOAuth, McpServersField, McpServerSource, McpServerSpec, McpTransport, Model, ModelAuthConfig, ModelEndpointConfig, ModelRef, ModelSpec, ModelTlsConfig, MonitorEntry, MonitorsField, ObjectMeta, OfficialMeta, Owner, PathOrPaths, PingBody, PingV0Data, PingV0Error, PingV0Errors, PingV0Response, PingV0Responses, Plugin, PluginAuthor, PluginChannel, PluginDependency, PluginEntry, PluginExperimental, PluginHook, PluginInventory, PluginManifest, PluginMarketplaceGetData, PluginMarketplaceGetError, PluginMarketplaceGetErrors, PluginMarketplaceGetResponse, PluginMarketplaceGetResponses, PluginResolvedSource, PluginSkill, PluginSource, PluginSourceGit, PluginSourceOci, PluginSpec, PluginStatus, PluginUserConfigField, Prompt, PromptSpec, Repository, ResourceRef, ResponseMeta, Runtime, RuntimeSpec, SecretEnvSource, SecretKeyRef, ServerArgument, ServerDetail, ServerInput, ServerListResponse, ServerPackage, ServerRepository, ServerResponse, ServerTransport, Skill, SkillResolvedSource, SkillSource, SkillSpec, SkillStatus, Status, VersionBody } from './types.gen'; +export type { Agent, AgentSource, AgentSpec, ApplyBatchData, ApplyBatchError, ApplyBatchErrors, ApplyBatchResponse, ApplyBatchResponses, ApplyDeploymentData, ApplyDeploymentError, ApplyDeploymentErrors, ApplyDeploymentResponse, ApplyDeploymentResponses, ApplyResult, ApplyResultsResponse, ApplyRuntimeData, ApplyRuntimeError, ApplyRuntimeErrors, ApplyRuntimeResponse, ApplyRuntimeResponses, ClientOptions, CommandEntry, CommandsField, ComponentRef, Condition, DeleteAgentData, DeleteAgentError, DeleteAgentErrors, DeleteAgentResponse, DeleteAgentResponses, DeleteBatchData, DeleteBatchError, DeleteBatchErrors, DeleteBatchResponse, DeleteBatchResponses, DeleteDeploymentData, DeleteDeploymentError, DeleteDeploymentErrors, DeleteDeploymentResponse, DeleteDeploymentResponses, DeleteMcpserverData, DeleteMcpserverError, DeleteMcpserverErrors, DeleteMcpserverResponse, DeleteMcpserverResponses, DeleteModelData, DeleteModelError, DeleteModelErrors, DeleteModelResponse, DeleteModelResponses, DeletePluginData, DeletePluginError, DeletePluginErrors, DeletePluginResponse, DeletePluginResponses, DeletePromptData, DeletePromptError, DeletePromptErrors, DeletePromptResponse, DeletePromptResponses, DeleteRuntimeData, DeleteRuntimeError, DeleteRuntimeErrors, DeleteRuntimeResponse, DeleteRuntimeResponses, DeleteSkillData, DeleteSkillError, DeleteSkillErrors, DeleteSkillResponse, DeleteSkillResponses, Deployment, DeploymentHarness, DeploymentRef, DeploymentSpec, EnvFromSource, ErrorDetail, ErrorModel, GetAgentData, GetAgentError, GetAgentErrors, GetAgentResponse, GetAgentResponses, GetHealthV0Data, GetHealthV0Error, GetHealthV0Errors, GetHealthV0Response, GetHealthV0Responses, GetLatestAgentData, GetLatestAgentError, GetLatestAgentErrors, GetLatestAgentResponse, GetLatestAgentResponses, GetLatestDeploymentData, GetLatestDeploymentError, GetLatestDeploymentErrors, GetLatestDeploymentResponse, GetLatestDeploymentResponses, GetLatestMcpserverData, GetLatestMcpserverError, GetLatestMcpserverErrors, GetLatestMcpserverResponse, GetLatestMcpserverResponses, GetLatestModelData, GetLatestModelError, GetLatestModelErrors, GetLatestModelResponse, GetLatestModelResponses, GetLatestPluginData, GetLatestPluginError, GetLatestPluginErrors, GetLatestPluginResponse, GetLatestPluginResponses, GetLatestPromptData, GetLatestPromptError, GetLatestPromptErrors, GetLatestPromptResponse, GetLatestPromptResponses, GetLatestRuntimeData, GetLatestRuntimeError, GetLatestRuntimeErrors, GetLatestRuntimeResponse, GetLatestRuntimeResponses, GetLatestSkillData, GetLatestSkillError, GetLatestSkillErrors, GetLatestSkillResponse, GetLatestSkillResponses, GetMcpserverData, GetMcpserverError, GetMcpserverErrors, GetMcpserverResponse, GetMcpserverResponses, GetModelData, GetModelError, GetModelErrors, GetModelResponse, GetModelResponses, GetPluginData, GetPluginError, GetPluginErrors, GetPluginResponse, GetPluginResponses, GetPromptData, GetPromptError, GetPromptErrors, GetPromptResponse, GetPromptResponses, GetSkillData, GetSkillError, GetSkillErrors, GetSkillResponse, GetSkillResponses, GetVersionV0Data, GetVersionV0Error, GetVersionV0Errors, GetVersionV0Response, GetVersionV0Responses, HarnessCompatibility, HealthBody, HookEntry, HookMatcherGroup, HooksField, HttpHeader, ListAgentsData, ListAgentsError, ListAgentsErrors, ListAgentsResponse, ListAgentsResponses, ListDeploymentsData, ListDeploymentsError, ListDeploymentsErrors, ListDeploymentsResponse, ListDeploymentsResponses, ListMcpserversData, ListMcpserversError, ListMcpserversErrors, ListMcpserversResponse, ListMcpserversResponses, ListMetadata, ListModelsData, ListModelsError, ListModelsErrors, ListModelsResponse, ListModelsResponses, ListOutputAgentBody, ListOutputDeploymentBody, ListOutputMcpServerBody, ListOutputModelBody, ListOutputPluginBody, ListOutputPromptBody, ListOutputRuntimeBody, ListOutputSkillBody, ListPluginsData, ListPluginsError, ListPluginsErrors, ListPluginsResponse, ListPluginsResponses, ListPromptsData, ListPromptsError, ListPromptsErrors, ListPromptsResponse, ListPromptsResponses, ListRuntimesData, ListRuntimesError, ListRuntimesErrors, ListRuntimesResponse, ListRuntimesResponses, ListSkillsData, ListSkillsError, ListSkillsErrors, ListSkillsResponse, ListSkillsResponses, ListTagsAgentData, ListTagsAgentError, ListTagsAgentErrors, ListTagsAgentResponse, ListTagsAgentResponses, ListTagsMcpserverData, ListTagsMcpserverError, ListTagsMcpserverErrors, ListTagsMcpserverResponse, ListTagsMcpserverResponses, ListTagsModelData, ListTagsModelError, ListTagsModelErrors, ListTagsModelResponse, ListTagsModelResponses, ListTagsPluginData, ListTagsPluginError, ListTagsPluginErrors, ListTagsPluginResponse, ListTagsPluginResponses, ListTagsPromptData, ListTagsPromptError, ListTagsPromptErrors, ListTagsPromptResponse, ListTagsPromptResponses, ListTagsSkillData, ListTagsSkillError, ListTagsSkillErrors, ListTagsSkillResponse, ListTagsSkillResponses, LspServerEntry, LspServersField, MarketplaceResponse, McpArgument, McpKeyValueInput, McpPackage, McpPackageLaunch, McpPackageOrigin, McpPackageOriginNpm, McpPackageOriginOci, McpPackageOriginPyPi, McpRegistryGetServerVersionData, McpRegistryGetServerVersionError, McpRegistryGetServerVersionErrors, McpRegistryGetServerVersionResponse, McpRegistryGetServerVersionResponses, McpRegistryListServersData, McpRegistryListServersError, McpRegistryListServersErrors, McpRegistryListServersResponse, McpRegistryListServersResponses, McpRegistryListServerVersionsData, McpRegistryListServerVersionsError, McpRegistryListServerVersionsErrors, McpRegistryListServerVersionsResponse, McpRegistryListServerVersionsResponses, McpRemote, McpServer, McpServerEntry, McpServerOAuth, McpServersField, McpServerSource, McpServerSpec, McpTransport, Model, ModelAuthConfig, ModelEndpointConfig, ModelRef, ModelSpec, ModelTlsConfig, MonitorEntry, MonitorsField, ObjectMeta, OfficialMeta, Owner, PathOrPaths, PingBody, PingV0Data, PingV0Error, PingV0Errors, PingV0Response, PingV0Responses, Plugin, PluginAuthor, PluginChannel, PluginDependency, PluginEntry, PluginExperimental, PluginHook, PluginInventory, PluginManifest, PluginMarketplaceGetData, PluginMarketplaceGetError, PluginMarketplaceGetErrors, PluginMarketplaceGetResponse, PluginMarketplaceGetResponses, PluginResolvedComponent, PluginResolvedSource, PluginSkill, PluginSource, PluginSourceGit, PluginSourceOci, PluginSpec, PluginStatus, PluginUserConfigField, Prompt, PromptSpec, Repository, ResourceRef, ResponseMeta, Runtime, RuntimeSpec, SecretEnvSource, SecretKeyRef, ServerArgument, ServerDetail, ServerInput, ServerListResponse, ServerPackage, ServerRepository, ServerResponse, ServerTransport, Skill, SkillResolvedSource, SkillSource, SkillSpec, SkillStatus, Status, VersionBody } from './types.gen'; diff --git a/ui/lib/api/types.gen.ts b/ui/lib/api/types.gen.ts index 770de93d5..d5051cefc 100644 --- a/ui/lib/api/types.gen.ts +++ b/ui/lib/api/types.gen.ts @@ -68,6 +68,12 @@ export type CommandsField = { Paths: PathOrPaths; }; +export type ComponentRef = { + name: string; + namespace?: string; + tag?: string; +}; + export type Condition = { lastTransitionTime?: string; message?: string; @@ -572,6 +578,15 @@ export type PluginManifest = { version?: string; }; +export type PluginResolvedComponent = { + commit?: string; + contentHash?: string; + kind: string; + name: string; + namespace: string; + tag: string; +}; + export type PluginResolvedSource = { commit?: string; digest?: string; @@ -598,9 +613,13 @@ export type PluginSourceOci = { }; export type PluginSpec = { + commands?: Array | null; description?: string; harnesses?: Array | null; iconUrl?: string; + instructions?: ComponentRef; + mcpServers?: Array | null; + skills?: Array | null; source?: PluginSource; title?: string; }; @@ -610,6 +629,7 @@ export type PluginStatus = { details?: unknown; inventory?: PluginInventory; manifest?: PluginManifest; + resolvedComponents?: Array | null; resolvedSource?: PluginResolvedSource; };