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 {