feat(addons): let a manifest declare the provider slots it serves

A provider slot is a job the host does itself and lets an add-on do in
its place. The host owns the slot, its gates and a built-in provider;
the person picks one provider.

Add the closed list of slots as data (provider-slots.json) with a
descriptor per slot: the choice a person has, the built-in provider, the
roles a provider can fill, the contributions the slot activates and the
facts a provider may declare. Add the optional manifest key
capabilities.provides, validated against the descriptor: the slot and
its version, the tools of each role (declared, of the right kind, not
page-only, not shadowing, one role each), typed properties, and the
context file scopes. The manifest stays at api 2.

Carry the declaration through the apps list and the tool adapter so the
host can list the add-ons that provide a slot.

Assisted-by: Claude:claude-sonnet-5-5 go
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjrQyk8ovFXvy5zrjHX1BD
This commit is contained in:
Ettore Di GiacintoandClaude Sonnet 5.5 committed 2026-11-05 00:59:43 +00:00
1 parent 849d985f81
commit fad0bf08eb
9 files changed
+614 -1

No files matched your search

+5
View File
@@ -209,6 +209,10 @@ type App struct {
// the prompt of a chat, from the verified manifest. The host reads them as
// the person; the add-on is not called.
ContextFiles []contract.ContextFile `json:"contextFiles,omitempty"`
// Provides are the provider slots the add-on declares, from the verified
// manifest. The host lists them for the person who picks a provider. It
// is not part of the apps API.
Provides []contract.ProviderDeclaration `json:"-"`
// UserSettings are the per-person choices the add-on declares, from the
// verified manifest. The host keeps the answers; the add-on is not called.
UserSettings []contract.UserSetting `json:"userSettings,omitempty"`
@@ -359,6 +363,7 @@ func build(s Snapshot, admin bool) App {
a.Skills = slices.Clone(m.Capabilities.SkillDefinitions)
a.SkillShadows = contract.ShadowedSkills(m.Capabilities.SkillDefinitions)
a.ContextFiles = slices.Clone(m.Capabilities.ContextFiles)
a.Provides = slices.Clone(m.Capabilities.Provides)
for _, us := range m.Capabilities.UserSettings {
us.Values = slices.Clone(us.Values)
a.UserSettings = append(a.UserSettings, us)
+1
View File
@@ -1569,6 +1569,7 @@ func (m *Manager) DeclaredCapabilities(ctx context.Context, id string, generatio
out.SkillDefinitions = append([]contract.SkillDefinition(nil), c.SkillDefinitions...)
out.ContextFiles = append([]contract.ContextFile(nil), c.ContextFiles...)
out.Credentials = slices.Clone(c.Credentials)
out.Provides = slices.Clone(c.Provides)
for _, us := range c.UserSettings {
us.Values = slices.Clone(us.Values)
out.UserSettings = append(out.UserSettings, us)
+32
View File
@@ -585,6 +585,38 @@ func (t *ToolAdapter) ContextFiles(ctx context.Context, actor contract.Actor) ([
return out, nil
}
// AddonProviders is the provider slots one add-on declares, with the title and
// description it shows to people.
type AddonProviders struct {
AddonID, Title, Description string
Provides []contract.ProviderDeclaration
// Running is true when the add-on may serve now: enabled and running.
Running bool
}
// Providers lists the installed add-ons that declare provider slots and that
// the actor can see, running or not. The host decides which one serves a slot;
// the add-on is not called.
func (t *ToolAdapter) Providers(ctx context.Context, actor contract.Actor) ([]AddonProviders, error) {
if err := checkActor(actor); err != nil {
return nil, err
}
list, err := t.backend.Apps(ctx, actor)
if err != nil {
return nil, gateway.ClassifyCheck(err)
}
var out []AddonProviders
for _, a := range list.Apps {
if len(a.Provides) == 0 {
continue
}
running := a.Enabled && a.State == "running" && a.Generation > 0
out = append(out, AddonProviders{AddonID: a.ID, Title: a.Title, Description: a.Description, Provides: slices.Clone(a.Provides), Running: running})
}
slices.SortFunc(out, func(x, y AddonProviders) int { return strings.Compare(x.AddonID, y.AddonID) })
return out, nil
}
// AddonUserSettings is the per-person settings one add-on declares.
type AddonUserSettings struct {
AddonID string
+1
View File
@@ -7,6 +7,7 @@ The typed declarations remain additive strict v1 fields, not a new protocol vers
- `capabilities.ui.homeCommands` names installed pages, never tools or install actions.
- `capabilities.executionProvider` names execution tools and one declared UI-only read status tool.
- `capabilities.hostOperations.required` and `.optional` request exact operation names, never grants.
- `capabilities.provides` says which provider slots of the host the add-on serves (`providerslots.go`, `provider-slots.json`). A slot is a job the host does itself and lets an add-on do in its place; the person picks one provider. The manifest names the slot, the version it was written for, tools for the roles of the slot and typed properties. It is an optional key under `api` 2.
- `capabilities.network` declares the egress policy of the commands an add-on runs (kind `egress-check`, a title, preset titles). It attaches the `network-policy` host control.
`ValidHostOp` validates syntax only. The host's registered vocabulary determines support.
+7
View File
@@ -140,6 +140,10 @@ type Capabilities struct {
// UserSettings declare the choices that each person makes for the add-on
// (see usersettings.go). The host keeps the answers and validates them.
UserSettings []UserSetting `json:"userSettings,omitempty"`
// Provides says which provider slots of the host the add-on can serve (see
// providerslots.go). The person picks one provider per slot; the add-on's
// context files and skills count only while it is the one picked.
Provides []ProviderDeclaration `json:"provides,omitempty"`
// Credentials declare the secret that the host keeps for the person and
// hands to each call (see credentials.go). It needs a connector
@@ -410,6 +414,9 @@ func ValidateManifest(m Manifest, h HostCompatibility) error {
if e := validateUserSettings(c.UserSettings); e != nil {
return e
}
if e := validateProvides(c); e != nil {
return e
}
if e := validateResourceTools(c); e != nil {
return e
}
+36
View File
@@ -0,0 +1,36 @@
[
{
"id": "memory",
"version": 1,
"minVersion": 1,
"title": "Memory",
"description": "Short notes the assistant reads before it answers and may save.",
"selection": {
"scope": "user",
"default": "builtin",
"offAllowed": true,
"offMeans": "Nothing is read, offered, saved or sent. Existing notes are kept."
},
"builtin": {
"id": "builtin",
"title": "Built-in",
"description": "Plain files in your Memory folder. Nothing leaves this machine."
},
"roles": {
"context": {"kinds": ["read"], "max": 1},
"read": {"kinds": ["read"], "max": 8},
"write": {"kinds": ["write"], "max": 4},
"export": {"kinds": ["read"], "max": 1},
"forget": {"kinds": ["write"], "max": 1}
},
"contributions": {
"contextFiles": ["user", "agent", "channel"],
"skillDefinitions": true
},
"properties": {
"remote": {"type": "bool"},
"undo": {"type": "enum", "values": ["host", "provider", "none"]}
},
"policy": ["confirm-writes", "save-levels", "messaging-trust", "no-background-writes", "prompt-budget", "untrusted-wrap", "audit", "off-precedence", "remote-disclosure"]
}
]
+366
View File
@@ -0,0 +1,366 @@
// SPDX-License-Identifier: MIT
package contract
import (
"bytes"
_ "embed"
"encoding/json"
"fmt"
"slices"
"sort"
"sync"
)
// Provider slots. A provider slot is a job the host does by itself and lets an
// add-on do in its place: the host owns the slot, its gates and its built-in
// provider; an add-on declares that it provides the slot
// (capabilities.provides) and the person picks one provider. The list of slots
// is data (provider-slots.json) and closed: only a host release adds a slot,
// and an add-on cannot invent one. The word "slot" also names the host slots
// of the UI (contributions.go) and the slots of skills (slots.go); this
// facility says provider slot everywhere.
// Bounds of a provider declaration.
const (
// MaxProvides is the most slots one add-on provides.
MaxProvides = 4
// MaxProviderRoleTools is the most tools one role of a declaration names,
// before the limit of the slot's own role.
MaxProviderRoleTools = 8
)
// The values of ProviderSelection.Default and of a stored selection that are
// not add-on IDs.
const (
// ProviderBuiltin selects the provider the host ships for the slot.
ProviderBuiltin = "builtin"
// ProviderOff selects no provider at all.
ProviderOff = "off"
)
// The value types of a provider property.
const (
PropertyBool = "bool"
PropertyEnum = "enum"
)
// ProviderSlot is the host's description of one provider slot.
type ProviderSlot struct {
ID string `json:"id"`
// Version is the current version of the slot's contract. MinVersion is the
// oldest one the host still accepts in a manifest.
Version int `json:"version"`
MinVersion int `json:"minVersion"`
Title string `json:"title"`
// Description is the English text shown in the list of providers.
Description string `json:"description"`
Selection ProviderSelection `json:"selection"`
Builtin ProviderBuiltinInfo `json:"builtin"`
// Roles name what a provider can supply. A role maps to tools the add-on
// declares in capabilities.tools.
Roles map[string]ProviderRole `json:"roles"`
// Contributions say which of the add-on's own declarations the slot
// activates while the add-on is the selected provider.
Contributions ProviderContributions `json:"contributions"`
// Properties are the typed facts an add-on may declare about itself.
Properties map[string]ProviderProperty `json:"properties,omitempty"`
// Policy are ids of host rules applied to every provider of the slot. The
// add-on cannot change or opt out of them.
Policy []string `json:"policy,omitempty"`
}
// ProviderSelection says how the person chooses a provider.
type ProviderSelection struct {
// Scope is "user": one choice per person.
Scope string `json:"scope"`
// Default is ProviderBuiltin.
Default string `json:"default"`
OffAllowed bool `json:"offAllowed"`
// OffMeans is the sentence the UI shows for Off.
OffMeans string `json:"offMeans,omitempty"`
}
// ProviderBuiltinInfo describes the provider the host ships.
type ProviderBuiltinInfo struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
}
// ProviderRole is what a provider can supply for a slot.
type ProviderRole struct {
// Kinds are the tool kinds the role accepts.
Kinds []string `json:"kinds"`
Required bool `json:"required,omitempty"`
// Max is the most tools the role takes. Zero means one.
Max int `json:"max,omitempty"`
}
// ProviderContributions are the declarations a slot activates by selection.
type ProviderContributions struct {
// ContextFiles are the scopes of context files the slot allows.
ContextFiles []string `json:"contextFiles,omitempty"`
SkillDefinitions bool `json:"skillDefinitions,omitempty"`
}
// ProviderProperty is the type of one property a provider declares.
type ProviderProperty struct {
Type string `json:"type"`
Values []string `json:"values,omitempty"`
}
//go:embed provider-slots.json
var providerSlotsJSON []byte
var providerSlots = sync.OnceValue(func() []ProviderSlot {
var out []ProviderSlot
d := json.NewDecoder(bytes.NewReader(providerSlotsJSON))
d.DisallowUnknownFields()
if err := d.Decode(&out); err != nil {
panic("contract: provider-slots.json: " + err.Error())
}
if err := validateProviderSlots(out); err != nil {
panic("contract: provider-slots.json: " + err.Error())
}
return out
})
// ProviderSlots are the provider slots of this host, in file order.
func ProviderSlots() []ProviderSlot {
src := providerSlots()
out := make([]ProviderSlot, len(src))
for i, s := range src {
out[i] = s.clone()
}
return out
}
// ProviderSlotByID returns the slot called id.
func ProviderSlotByID(id string) (ProviderSlot, bool) {
for _, s := range providerSlots() {
if s.ID == id {
return s.clone(), true
}
}
return ProviderSlot{}, false
}
func (s ProviderSlot) clone() ProviderSlot {
roles := make(map[string]ProviderRole, len(s.Roles))
for k, r := range s.Roles {
r.Kinds = slices.Clone(r.Kinds)
roles[k] = r
}
s.Roles = roles
props := make(map[string]ProviderProperty, len(s.Properties))
for k, p := range s.Properties {
p.Values = slices.Clone(p.Values)
props[k] = p
}
s.Properties = props
s.Contributions.ContextFiles = slices.Clone(s.Contributions.ContextFiles)
s.Policy = slices.Clone(s.Policy)
return s
}
// RoleNames returns the role names of the slot, sorted.
func (s ProviderSlot) RoleNames() []string {
out := make([]string, 0, len(s.Roles))
for k := range s.Roles {
out = append(out, k)
}
sort.Strings(out)
return out
}
// validateProviderSlots checks the data file at start, so a malformed
// descriptor stops the host instead of weakening a check.
func validateProviderSlots(slots []ProviderSlot) error {
seen := map[string]bool{}
for _, s := range slots {
if !ValidName(s.ID) || seen[s.ID] {
return fmt.Errorf("invalid or duplicate slot id %q", s.ID)
}
seen[s.ID] = true
if s.Version < 1 || s.MinVersion < 1 || s.MinVersion > s.Version {
return fmt.Errorf("slot %s: invalid version range", s.ID)
}
if !plainText(s.Title, MaxTitleChars) || !plainText(s.Description, MaxDescriptionChars) {
return fmt.Errorf("slot %s: invalid title or description", s.ID)
}
if s.Selection.Scope != "user" || s.Selection.Default != ProviderBuiltin && !(s.Selection.Default == ProviderOff && s.Selection.OffAllowed) {
return fmt.Errorf("slot %s: invalid selection", s.ID)
}
if s.Selection.OffMeans != "" && !plainText(s.Selection.OffMeans, MaxDescriptionChars) {
return fmt.Errorf("slot %s: invalid off text", s.ID)
}
if !ValidName(s.Builtin.ID) || s.Builtin.ID != ProviderBuiltin || !plainText(s.Builtin.Title, MaxTitleChars) || !plainText(s.Builtin.Description, MaxDescriptionChars) {
return fmt.Errorf("slot %s: invalid built-in provider", s.ID)
}
for name, r := range s.Roles {
if !ValidName(name) || len(r.Kinds) == 0 || r.Max < 0 || r.Max > MaxProviderRoleTools {
return fmt.Errorf("slot %s: invalid role %q", s.ID, name)
}
for _, k := range r.Kinds {
// A provider never brings a tool that changes things at once.
if k != ToolKindRead && k != ToolKindWrite {
return fmt.Errorf("slot %s: role %s: kind %q is not allowed", s.ID, name, k)
}
}
}
for _, sc := range s.Contributions.ContextFiles {
if placeholderOf(ChangeScope(sc)) == "" && ChangeScope(sc) != ScopeUser {
return fmt.Errorf("slot %s: unknown context scope %q", s.ID, sc)
}
}
for name, p := range s.Properties {
if !ValidName(name) || p.Type != PropertyBool && p.Type != PropertyEnum || p.Type == PropertyEnum && len(p.Values) == 0 {
return fmt.Errorf("slot %s: invalid property %q", s.ID, name)
}
}
}
return nil
}
// ProviderDeclaration is the manifest's statement that the add-on provides a
// slot (capabilities.provides).
type ProviderDeclaration struct {
// Slot names a slot of the host. The list is closed.
Slot string `json:"slot"`
// SlotVersion is the version of the slot's contract the add-on was
// written for.
SlotVersion int `json:"slotVersion"`
// Roles map a role of the slot to tools of capabilities.tools.
Roles map[string]ProviderTools `json:"roles,omitempty"`
// Properties are typed facts the slot asks for: a boolean or one of the
// listed values.
Properties map[string]any `json:"properties,omitempty"`
}
// ProviderTools is the tool or the tools that serve one role. The manifest
// writes one name as a string and several as a list.
type ProviderTools []string
func (p *ProviderTools) UnmarshalJSON(b []byte) error {
b = bytes.TrimSpace(b)
if len(b) > 0 && b[0] == '"' {
var one string
if err := json.Unmarshal(b, &one); err != nil {
return err
}
*p = ProviderTools{one}
return nil
}
var many []string
if err := json.Unmarshal(b, &many); err != nil {
return fmt.Errorf("a role names a tool or a list of tools")
}
*p = many
return nil
}
// ProvidedSlots returns the IDs of the slots the capabilities declare.
func (c Capabilities) ProvidedSlots() []string {
out := make([]string, 0, len(c.Provides))
for _, d := range c.Provides {
out = append(out, d.Slot)
}
return out
}
// ProvidesSlot reports whether the capabilities declare the slot.
func (c Capabilities) ProvidesSlot(slot string) bool {
return slices.Contains(c.ProvidedSlots(), slot)
}
// validateProvides checks every declaration against the host's descriptor of
// its slot. Nothing here names a slot: the rules come from the data.
func validateProvides(c Capabilities) error {
if len(c.Provides) == 0 {
return nil
}
if len(c.Provides) > MaxProvides {
return fmt.Errorf("an add-on provides at most %d slots", MaxProvides)
}
defs := map[string]ToolDefinition{}
for _, d := range c.ToolDefinitions {
defs[d.Name] = d
}
scopes := map[string]bool{}
seen := map[string]bool{}
for _, d := range c.Provides {
slot, ok := ProviderSlotByID(d.Slot)
if !ok {
return fmt.Errorf("unknown provider slot %q", d.Slot)
}
if seen[d.Slot] {
return fmt.Errorf("provider slot %q is declared twice", d.Slot)
}
seen[d.Slot] = true
if d.SlotVersion < slot.MinVersion || d.SlotVersion > slot.Version {
return fmt.Errorf("provider slot %s: slotVersion must be %d to %d", d.Slot, slot.MinVersion, slot.Version)
}
used := map[string]string{}
for name, tools := range d.Roles {
role, ok := slot.Roles[name]
if !ok {
return fmt.Errorf("provider slot %s: unknown role %q", d.Slot, name)
}
limit := max(role.Max, 1)
if len(tools) == 0 || len(tools) > limit {
return fmt.Errorf("provider slot %s: role %s takes 1 to %d tools", d.Slot, name, limit)
}
for _, t := range tools {
def, ok := defs[t]
switch {
case !ok || !slices.Contains(c.Tools, t):
return fmt.Errorf("provider slot %s: role %s names %q, which is not a declared tool with a definition", d.Slot, name, t)
case !slices.Contains(role.Kinds, def.Kind):
return fmt.Errorf("provider slot %s: role %s needs a tool of kind %v, %q is %s", d.Slot, name, role.Kinds, t, def.Kind)
case def.UIOnly || def.Shadows != "":
return fmt.Errorf("provider slot %s: role %s: tool %q must be a plain model tool", d.Slot, name, t)
case used[t] != "":
return fmt.Errorf("provider slot %s: tool %q serves both %s and %s", d.Slot, t, used[t], name)
}
used[t] = name
}
}
for name, role := range slot.Roles {
if role.Required && len(d.Roles[name]) == 0 {
return fmt.Errorf("provider slot %s: role %s is required", d.Slot, name)
}
}
for key, v := range d.Properties {
p, ok := slot.Properties[key]
if !ok {
return fmt.Errorf("provider slot %s: unknown property %q", d.Slot, key)
}
switch val := v.(type) {
case bool:
if p.Type != PropertyBool {
return fmt.Errorf("provider slot %s: property %s must be one of %v", d.Slot, key, p.Values)
}
case string:
if p.Type != PropertyEnum || !slices.Contains(p.Values, val) {
return fmt.Errorf("provider slot %s: property %s has a value that is not allowed", d.Slot, key)
}
default:
return fmt.Errorf("provider slot %s: property %s has an invalid value", d.Slot, key)
}
}
// A provider that says its changes are undone by the add-on keeps the
// change sets that the host's undo calls read.
if v, _ := d.Properties["undo"].(string); v == "provider" && !c.Undo {
return fmt.Errorf("provider slot %s: undo \"provider\" needs capabilities.undo", d.Slot)
}
for _, sc := range slot.Contributions.ContextFiles {
scopes[sc] = true
}
}
for _, f := range c.ContextFiles {
if !scopes[string(f.Scope)] {
return fmt.Errorf("context file scope %q is not allowed by the slots the add-on provides", f.Scope)
}
}
return nil
}
+154
View File
@@ -0,0 +1,154 @@
// SPDX-License-Identifier: MIT
package contract
import (
"encoding/json"
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("The provider slot declaration", func() {
schema := json.RawMessage(`{"type":"object","properties":{"query":{"type":"string"}},"additionalProperties":false}`)
// A write tool names the resource it changes, as the contract requires.
withResource := json.RawMessage(`{"type":"object","properties":{"resourceId":{"type":"string"},"text":{"type":"string"}},"required":["resourceId"],"additionalProperties":false}`)
tool := func(name, kind string) ToolDefinition {
in := schema
if kind == ToolKindWrite {
in = withResource
}
return ToolDefinition{Name: name, Description: "does " + name, Kind: kind, InputSchema: in}
}
// provider is a manifest that declares the given provider entries and the
// tools they name, on top of the fixture.
provider := func(d ProviderDeclaration) Manifest {
m, err := ParseManifest(fixture())
Expect(err).NotTo(HaveOccurred())
m.Capabilities.Tools = append(m.Capabilities.Tools, "recall", "remember", "export-notes")
m.Capabilities.ToolDefinitions = append(m.Capabilities.ToolDefinitions,
tool("recall", ToolKindRead), tool("remember", ToolKindWrite), tool("export-notes", ToolKindRead))
m.Capabilities.Provides = []ProviderDeclaration{d}
return m
}
good := func() ProviderDeclaration {
return ProviderDeclaration{Slot: "memory", SlotVersion: 1,
Roles: map[string]ProviderTools{"read": {"recall"}, "write": {"remember"}, "export": {"export-notes"}},
Properties: map[string]any{"remote": true, "undo": "none"}}
}
It("reads the slots of the host from data and keeps them closed", func() {
slot, ok := ProviderSlotByID("memory")
Expect(ok).To(BeTrue())
Expect(slot.Selection.OffAllowed).To(BeTrue())
Expect(slot.Builtin.ID).To(Equal(ProviderBuiltin))
Expect(slot.RoleNames()).To(ConsistOf("context", "read", "write", "export", "forget"))
Expect(ProviderSlots()).NotTo(BeEmpty())
_, ok = ProviderSlotByID("calendar")
Expect(ok).To(BeFalse())
// A caller cannot change the table.
slot.Roles["read"] = ProviderRole{}
again, _ := ProviderSlotByID("memory")
Expect(again.Roles["read"].Kinds).To(Equal([]string{ToolKindRead}))
})
It("accepts an add-on whose roles name its own tools of the right kind", func() {
m := provider(good())
Expect(ValidateManifest(m, compatible())).To(Succeed())
Expect(m.Capabilities.ProvidesSlot("memory")).To(BeTrue())
Expect(m.Capabilities.ProvidedSlots()).To(Equal([]string{"memory"}))
})
It("accepts a declaration with no roles, as a file-based provider has none", func() {
Expect(ValidateManifest(provider(ProviderDeclaration{Slot: "memory", SlotVersion: 1}), compatible())).To(Succeed())
})
It("reads one tool as a string and several as a list, and refuses anything else", func() {
var d ProviderDeclaration
Expect(json.Unmarshal([]byte(`{"slot":"memory","slotVersion":1,"roles":{"read":"recall","write":["remember"]}}`), &d)).To(Succeed())
Expect(d.Roles["read"]).To(Equal(ProviderTools{"recall"}))
Expect(d.Roles["write"]).To(Equal(ProviderTools{"remember"}))
Expect(json.Unmarshal([]byte(`{"slot":"memory","roles":{"read":5}}`), &d)).NotTo(Succeed())
})
It("is parsed strictly from a manifest file", func() {
m := provider(good())
raw, err := json.Marshal(m)
Expect(err).NotTo(HaveOccurred())
var generic map[string]any
Expect(json.Unmarshal(raw, &generic)).To(Succeed())
generic["capabilities"].(map[string]any)["provides"].([]any)[0].(map[string]any)["extra"] = 1
bad, _ := json.Marshal(generic)
_, err = ParseManifest(bad)
Expect(err).To(HaveOccurred(), "an unknown field of a declaration fails")
})
It("refuses what the descriptor does not allow", func() {
for name, edit := range map[string]func(*ProviderDeclaration){
"an unknown slot": func(d *ProviderDeclaration) { d.Slot = "calendar" },
"a version from the future": func(d *ProviderDeclaration) { d.SlotVersion = 2 },
"no version": func(d *ProviderDeclaration) { d.SlotVersion = 0 },
"an unknown role": func(d *ProviderDeclaration) { d.Roles["sync"] = ProviderTools{"recall"} },
"a tool that is not declared": func(d *ProviderDeclaration) { d.Roles["read"] = ProviderTools{"missing"} },
"a write tool in a read role": func(d *ProviderDeclaration) { d.Roles["read"] = ProviderTools{"remember"} },
"a read tool in a write role": func(d *ProviderDeclaration) { d.Roles["write"] = ProviderTools{"recall"} },
"too many tools in a role": func(d *ProviderDeclaration) { d.Roles["export"] = ProviderTools{"export-notes", "recall"} },
"a role with no tool": func(d *ProviderDeclaration) { d.Roles["read"] = ProviderTools{} },
"one tool in two roles": func(d *ProviderDeclaration) { d.Roles["export"] = ProviderTools{"recall"} },
"an unknown property": func(d *ProviderDeclaration) { d.Properties["colour"] = true },
"a property of the wrong type": func(d *ProviderDeclaration) { d.Properties["remote"] = "yes" },
"a value not in the list": func(d *ProviderDeclaration) { d.Properties["undo"] = "maybe" },
"undo by the add-on without the undo capability": func(d *ProviderDeclaration) {
d.Properties["undo"] = "provider"
},
} {
d := good()
edit(&d)
Expect(ValidateManifest(provider(d), compatible())).NotTo(Succeed(), name)
}
})
It("refuses a tool that only the add-on's page may call", func() {
m := provider(good())
for i := range m.Capabilities.ToolDefinitions {
if m.Capabilities.ToolDefinitions[i].Name == "recall" {
m.Capabilities.ToolDefinitions[i].UIOnly = true
}
}
Expect(ValidateManifest(m, compatible())).NotTo(Succeed())
})
It("accepts undo by the add-on when it keeps change sets", func() {
m := provider(good())
m.Capabilities.Provides[0].Properties["undo"] = "provider"
m.Capabilities.Undo = true
Expect(ValidateManifest(m, compatible())).To(Succeed())
})
It("refuses a repeated slot and more slots than the bound", func() {
m := provider(good())
m.Capabilities.Provides = append(m.Capabilities.Provides, ProviderDeclaration{Slot: "memory", SlotVersion: 1})
Expect(ValidateManifest(m, compatible())).NotTo(Succeed())
m.Capabilities.Provides = nil
for range MaxProvides + 1 {
m.Capabilities.Provides = append(m.Capabilities.Provides, ProviderDeclaration{Slot: "memory", SlotVersion: 1})
}
Expect(ValidateManifest(m, compatible())).NotTo(Succeed())
})
It("limits the context files to the scopes the slot allows", func() {
m := provider(ProviderDeclaration{Slot: "memory", SlotVersion: 1})
m.Capabilities.ContextFiles = []ContextFile{{Scope: ScopeUser, Path: ContextFilesRoot + "index.md"}}
Expect(ValidateManifest(m, compatible())).To(Succeed())
m.Capabilities.ContextFiles = []ContextFile{{Scope: ChangeScope("team"), Path: ContextFilesRoot + "index.md"}}
Expect(ValidateManifest(m, compatible())).NotTo(Succeed())
})
It("keeps api 2 as the only manifest api", func() {
m := provider(good())
m.API = VersionRange{Min: 1, Max: 1}
err := ValidateManifest(m, compatible())
Expect(err).To(HaveOccurred())
Expect(strings.ToLower(err.Error())).To(ContainSubstring("api"))
})
})
+12 -1
View File
@@ -502,7 +502,18 @@
"the header is used without an explicit language",
"translates only a kind that the manifest declares",
"translates only a text that the English kind has",
"zh finds zh-CN"
"zh finds zh-CN",
"reads the slots of the host from data and keeps them closed",
"accepts an add-on whose roles name its own tools of the right kind",
"accepts a declaration with no roles, as a file-based provider has none",
"reads one tool as a string and several as a list, and refuses anything else",
"is parsed strictly from a manifest file",
"refuses what the descriptor does not allow",
"refuses a tool that only the add-on's page may call",
"accepts undo by the add-on when it keeps change sets",
"refuses a repeated slot and more slots than the bound",
"limits the context files to the scopes the slot allows",
"keeps api 2 as the only manifest api"
]
},
{