diff --git a/core/application/application.go b/core/application/application.go index 4c081f9f5d..3f5d35d6bb 100644 --- a/core/application/application.go +++ b/core/application/application.go @@ -979,7 +979,7 @@ func (a *Application) startAddons() error { } host := c.Host if host == (contract.HostCompatibility{}) { - host = contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: contract.HostSDKVersion, DataFormat: 1, OS: runtime.GOOS, Arch: runtime.GOARCH} + host = contract.HostCompatibility{Host: 1, API: contract.HostAPIVersion, APIMin: contract.HostAPIMin, Protocol: 1, SDK: contract.HostSDKVersion, DataFormat: 1, OS: runtime.GOOS, Arch: runtime.GOARCH} } cat = catalog.Catalog{Sources: sources, TargetOS: host.OS, TargetArch: host.Arch} installer = &packages.Installer{Root: filepath.Join(c.Root, "packages"), Catalog: cat, Host: host} diff --git a/core/services/addons/runtime/supervisor.go b/core/services/addons/runtime/supervisor.go index 5659131ce8..e9136b0bc3 100644 --- a/core/services/addons/runtime/supervisor.go +++ b/core/services/addons/runtime/supervisor.go @@ -284,7 +284,7 @@ func (s *Supervisor) start(ctx context.Context, pkg packages.PackageReceipt, gen func (s *Supervisor) launch(ctx context.Context, pkg packages.PackageReceipt, generation uint64) (*process, error) { ctx, cancel := context.WithTimeout(ctx, s.config.ReadyTimeout) defer cancel() - host := contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: contract.HostSDKVersion, DataFormat: 1, OS: goruntime.GOOS, Arch: goruntime.GOARCH} + host := contract.HostCompatibility{Host: 1, API: contract.HostAPIVersion, APIMin: contract.HostAPIMin, Protocol: 1, SDK: contract.HostSDKVersion, DataFormat: 1, OS: goruntime.GOOS, Arch: goruntime.GOARCH} if contract.ValidateManifest(pkg.Manifest, host) != nil || !filepath.IsAbs(pkg.Directory) { return nil, ErrRuntime } diff --git a/pkg/addons/contract/execution.go b/pkg/addons/contract/execution.go new file mode 100644 index 0000000000..b219ee6555 --- /dev/null +++ b/pkg/addons/contract/execution.go @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT +package contract + +import ( + "fmt" + "slices" +) + +// MinExecutionAPI is the first manifest API version that knows the execution +// descriptor. A manifest that declares one must accept it. +const MinExecutionAPI uint64 = 2 + +// ExecutionClassCommands is the class of a tool that runs shell commands and +// code: the tool that shadows bash. +const ExecutionClassCommands = "commands" + +// Kinds of skills a commands provider can supply the runtime for. +const ( + // SkillKindDocuments are the skills of the document slots (see + // DocumentSkill). They need a provider that reports isolated execution. + SkillKindDocuments = "documents" +) + +// Limits of the text the host shows for an execution provider. +const ( + MaxExecutionTitleChars = 40 + MaxExecutionNoteChars = 600 +) + +// ExecutionDescriptor is what an add-on declares about the tool that runs +// commands. It carries no authority. Whether commands are ready and isolated +// is what the provider reports at run time (ExecutionStatus); the host decides +// from that report and never from the add-on's ID. +type ExecutionDescriptor struct { + // Class is ExecutionClassCommands. + Class string `json:"class"` + // Title names the provider in the text the host shows the person and the + // model, for example "Sandbox". It falls back to the add-on's title. + Title string `json:"title,omitempty"` + // Note is one plain sentence for the model when the provider reports + // isolated execution, such as the programs its environment has. + Note string `json:"note,omitempty"` + // Skills says which skills run on this provider. + Skills *ExecutionSkills `json:"skills,omitempty"` +} + +// ExecutionSkills declares the skills whose runtime the provider supplies. +type ExecutionSkills struct { + // ProvidesRuntimeFor lists skill kinds (SkillKindDocuments). The host + // offers the add-on's skills of those kinds only while the provider + // reports isolated execution. + ProvidesRuntimeFor []string `json:"providesRuntimeFor,omitempty"` +} + +// validate checks the descriptor of tool d. A nil descriptor is valid. +func (e *ExecutionDescriptor) validate(d ToolDefinition) error { + if e == nil { + return nil + } + if e.Class != ExecutionClassCommands || d.Shadows != "bash" { + return fmt.Errorf("tool %s: an execution descriptor has class %q and belongs to a tool that shadows bash", d.Name, ExecutionClassCommands) + } + if e.Title != "" && !plainText(e.Title, MaxExecutionTitleChars) { + return fmt.Errorf("tool %s: invalid execution title", d.Name) + } + if e.Note != "" && !plainText(e.Note, MaxExecutionNoteChars) { + return fmt.Errorf("tool %s: invalid execution note", d.Name) + } + if e.Skills != nil { + for _, k := range e.Skills.ProvidesRuntimeFor { + if k != SkillKindDocuments { + return fmt.Errorf("tool %s: unknown skill kind %q", d.Name, k) + } + } + } + return nil +} + +// CommandsTool returns the tool of the definitions that shadows bash. +func CommandsTool(defs []ToolDefinition) (ToolDefinition, bool) { + i := slices.IndexFunc(defs, func(d ToolDefinition) bool { return d.Shadows == "bash" && ValidateShadow(d) == nil }) + if i < 0 { + return ToolDefinition{}, false + } + return defs[i], true +} + +// IsolatedSkills lists the skills of an add-on that need its own commands +// provider to report isolated execution: the document-slot skills of an +// add-on with a commands tool, unless its descriptor says it does not provide +// the runtime for documents. A commands tool without a descriptor (a manifest +// of API 1) counts as providing it, so such an add-on keeps the check. +func IsolatedSkills(defs []ToolDefinition, skills []SkillDefinition) []string { + tool, ok := CommandsTool(defs) + if !ok { + return nil + } + if e := tool.Execution; e != nil && (e.Skills == nil || !slices.Contains(e.Skills.ProvidesRuntimeFor, SkillKindDocuments)) { + return nil + } + return ShadowedSkills(skills) +} diff --git a/pkg/addons/contract/execution_test.go b/pkg/addons/contract/execution_test.go new file mode 100644 index 0000000000..9d5a12ea71 --- /dev/null +++ b/pkg/addons/contract/execution_test.go @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +package contract + +import ( + "encoding/json" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("The execution descriptor of a commands tool", func() { + const schema = `{"type":"object","properties":{"command":{"type":"string"}},"required":["command"],"additionalProperties":false}` + manifestWith := func(e *ExecutionDescriptor, api VersionRange) Manifest { + m, err := ParseManifest(fixture()) + Expect(err).NotTo(HaveOccurred()) + m.API = api + m.Capabilities = Capabilities{ + Tools: []string{"run"}, + Actions: true, + ToolDefinitions: []ToolDefinition{{Name: "run", Description: "Run a command.", Kind: ToolKindAction, + InputSchema: json.RawMessage(schema), Shadows: "bash", Execution: e}}, + } + return m + } + host := func() HostCompatibility { + h := compatible() + h.SDK = 0 + h.API, h.APIMin = HostAPIVersion, HostAPIMin + return h + } + commands := &ExecutionDescriptor{Class: ExecutionClassCommands, Title: "Runbox", Note: "It has python3 and jq."} + + It("is valid for a tool that shadows bash when the manifest accepts API 2", func() { + Expect(ValidateManifest(manifestWith(commands, VersionRange{Min: 1, Max: 2}), host())).To(Succeed()) + }) + It("needs the manifest to accept API 2", func() { + Expect(ValidateManifest(manifestWith(commands, VersionRange{Min: 1, Max: 1}), host())).NotTo(Succeed()) + }) + It("is refused on a tool that does not shadow bash, with another class or with bad text", func() { + m := manifestWith(commands, VersionRange{Min: 1, Max: 2}) + m.Capabilities.ToolDefinitions[0].Shadows = "" + Expect(ValidateManifest(m, host())).NotTo(Succeed()) + for _, bad := range []*ExecutionDescriptor{ + {Class: "pages"}, + {Class: ExecutionClassCommands, Title: "Runbox"}, + {Class: ExecutionClassCommands, Note: string(make([]rune, MaxExecutionNoteChars+1))}, + {Class: ExecutionClassCommands, Skills: &ExecutionSkills{ProvidesRuntimeFor: []string{"games"}}}, + } { + Expect(ValidateManifest(manifestWith(bad, VersionRange{Min: 1, Max: 2}), host())).NotTo(Succeed()) + } + }) + + It("keeps a manifest of API 1 valid on a host that speaks API 2, and refuses one that needs API 3", func() { + Expect(ValidateManifest(manifestWith(nil, VersionRange{Min: 1, Max: 1}), host())).To(Succeed()) + Expect(ValidateManifest(manifestWith(nil, VersionRange{Min: 3, Max: 3}), host())).NotTo(Succeed()) + }) + + Describe("IsolatedSkills", func() { + skills := []SkillDefinition{{Name: "docx", Shadows: "docx"}, {Name: "runbox-tips"}} + defs := func(e *ExecutionDescriptor) []ToolDefinition { + return manifestWith(e, VersionRange{Min: 1, Max: 2}).Capabilities.ToolDefinitions + } + It("names the document skills of a provider that declares it supplies their runtime", func() { + e := &ExecutionDescriptor{Class: ExecutionClassCommands, Skills: &ExecutionSkills{ProvidesRuntimeFor: []string{SkillKindDocuments}}} + Expect(IsolatedSkills(defs(e), skills)).To(Equal([]string{"docx"})) + }) + It("fails closed for a commands tool without a descriptor", func() { + Expect(IsolatedSkills(defs(nil), skills)).To(Equal([]string{"docx"})) + }) + It("names nothing for a descriptor that does not supply the runtime of documents", func() { + Expect(IsolatedSkills(defs(commands), skills)).To(BeEmpty()) + }) + It("names nothing for an add-on without a commands tool", func() { + Expect(IsolatedSkills(nil, skills)).To(BeEmpty()) + }) + }) +}) diff --git a/pkg/addons/contract/manifest.go b/pkg/addons/contract/manifest.go index 81f7e731fd..aea55ce381 100644 --- a/pkg/addons/contract/manifest.go +++ b/pkg/addons/contract/manifest.go @@ -58,9 +58,25 @@ func (r VersionRange) accepts(v uint64) bool { return r.Min > 0 && r.Max >= r.Min && v >= r.Min && v <= r.Max } +// HostAPIVersion is the manifest API version this host speaks. Version 2 adds +// the execution descriptor of a tool. The host still accepts add-ons that +// declare only version 1 (HostAPIMin), because every change since is optional. +const ( + HostAPIVersion uint64 = 2 + HostAPIMin uint64 = 1 +) + +// overlaps reports whether the range holds a version from lo to hi. +func (r VersionRange) overlaps(lo, hi uint64) bool { + return r.Min > 0 && r.Max >= r.Min && r.Min <= hi && r.Max >= lo +} + type HostCompatibility struct { - Host uint64 `json:"host"` - API uint64 `json:"api"` + Host uint64 `json:"host"` + API uint64 `json:"api"` + // APIMin is the oldest manifest API the host still accepts. Zero means API + // only. + APIMin uint64 `json:"apiMin,omitempty"` Protocol uint64 `json:"protocol"` SDK uint64 `json:"sdk"` DataFormat uint64 `json:"dataFormat"` @@ -347,7 +363,11 @@ func ValidateManifest(m Manifest, h HostCompatibility) error { if !namespace.MatchString(m.OS) || !namespace.MatchString(m.Arch) || m.OS != h.OS || m.Arch != h.Arch { return fmt.Errorf("incompatible platform") } - if !m.Host.accepts(h.Host) || !m.API.accepts(h.API) || !m.Protocol.accepts(h.Protocol) || !m.DataFormat.accepts(h.DataFormat) { + apiMin := h.APIMin + if apiMin == 0 || apiMin > h.API { + apiMin = h.API + } + if !m.Host.accepts(h.Host) || !m.API.overlaps(apiMin, h.API) || !m.Protocol.accepts(h.Protocol) || !m.DataFormat.accepts(h.DataFormat) { return fmt.Errorf("incompatible host, API, protocol or data format") } c := m.Capabilities @@ -355,6 +375,9 @@ func ValidateManifest(m Manifest, h HostCompatibility) error { return fmt.Errorf("invalid capabilities") } for _, d := range c.ToolDefinitions { + if d.Execution != nil && m.API.Max < MinExecutionAPI { + return fmt.Errorf("tool %s declares an execution descriptor, which needs api %d", d.Name, MinExecutionAPI) + } if d.Kind == ToolKindAction && !c.Actions { return fmt.Errorf("tool %s is an action tool but the add-on does not declare actions", d.Name) } diff --git a/pkg/addons/contract/tooldefs.go b/pkg/addons/contract/tooldefs.go index 5ec9168fea..c7641c46a0 100644 --- a/pkg/addons/contract/tooldefs.go +++ b/pkg/addons/contract/tooldefs.go @@ -73,6 +73,10 @@ type ToolDefinition struct { // Only a name in ReplaceableTools may be shadowed. A tool without it keeps // its add-on name (addon____). Shadows string `json:"shadows,omitempty"` + // Execution declares that this tool runs commands and what the host may + // say about the provider. Only a tool that shadows bash may declare it, and + // the manifest must then accept API 2 (see MinExecutionAPI). + Execution *ExecutionDescriptor `json:"execution,omitempty"` } // ReplaceableTool says what a tool that shadows a built-in chat tool must look @@ -222,6 +226,9 @@ func validateToolDefinitions(defs []ToolDefinition, tools []string) error { if d.UIOnly && !IsResourceless(d) { return fmt.Errorf("tool %s: a page-only tool must be a read tool without a resource", d.Name) } + if err := d.Execution.validate(d); err != nil { + return err + } if d.Shadows != "" { if err := ValidateShadow(d); err != nil { return err diff --git a/pkg/addons/packaging/build.go b/pkg/addons/packaging/build.go index 0e803e95c7..f54be14026 100644 --- a/pkg/addons/packaging/build.go +++ b/pkg/addons/packaging/build.go @@ -32,7 +32,7 @@ import ( // does not contain them is not installable and must not be published. func hostFor(platform string) contract.HostCompatibility { os_, arch, _ := strings.Cut(platform, "/") - return contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: contract.HostSDKVersion, DataFormat: 1, OS: os_, Arch: arch} + return contract.HostCompatibility{Host: 1, API: contract.HostAPIVersion, APIMin: contract.HostAPIMin, Protocol: 1, SDK: contract.HostSDKVersion, DataFormat: 1, OS: os_, Arch: arch} } type fileEntry struct {