From 0c08e17a309ff89cce9cb4aeb905ae6b4c0ff408 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 09:42:34 +0000 Subject: [PATCH 1/5] feat(addons): add an execution descriptor to the tool contract A tool that shadows bash can now declare an execution descriptor: its class, a title and a note for the model, and the skill kinds whose runtime it supplies. The host reads it to build its text and to decide which skills need isolated execution, instead of knowing add-on IDs. The field is optional and needs manifest API 2. The host now speaks API 2 and still accepts add-ons that declare only API 1, so manifests that declare nothing new keep working unchanged. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto --- core/application/application.go | 2 +- core/services/addons/runtime/supervisor.go | 2 +- pkg/addons/contract/execution.go | 102 +++++++++++++++++++++ pkg/addons/contract/execution_test.go | 77 ++++++++++++++++ pkg/addons/contract/manifest.go | 29 +++++- pkg/addons/contract/tooldefs.go | 7 ++ pkg/addons/packaging/build.go | 2 +- 7 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 pkg/addons/contract/execution.go create mode 100644 pkg/addons/contract/execution_test.go diff --git a/core/application/application.go b/core/application/application.go index 4c081f9f5..3f5d35d6b 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 5659131ce..e9136b0bc 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 000000000..b219ee655 --- /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 000000000..9d5a12ea7 --- /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 81f7e731f..aea55ce38 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 5ec9168fe..c7641c46a 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 0e803e95c..f54be1402 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 { From 0c72b2c96fd9cb93494dcdf9390e91e8c509ca75 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 09:49:42 +0000 Subject: [PATCH 2/5] refactor(studio): decide command eligibility from the execution status Document skills, the Commands block of the prompt and load_skill no longer compare an add-on ID or provider with "sandbox". The host asks whether the add-on that ships a skill serves the chat's commands and whether the typed execution status reports them isolated. Skills that need that are listed by the broker from the add-on's manifest, and the text names the provider by the title its manifest declares. An add-on whose commands tool declares no descriptor behaves as a provider of commands under its own title and keeps the isolation check for its document skills. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto --- core/application/addon_gateway.go | 4 +- core/services/addons/tools/adapter.go | 27 ++++- .../services/addons/tools/definitions_test.go | 32 ++++++ core/services/studio/addonskills.go | 32 +++--- core/services/studio/addontools.go | 4 + core/services/studio/capabilities.go | 102 ++++++++++++++---- core/services/studio/capabilities_test.go | 59 ++++++++-- .../documenteligibility_internal_test.go | 83 +++++++++----- core/services/studio/loadskill.go | 16 +-- core/services/studio/session/harness.go | 5 + core/services/studio/shadow.go | 2 +- core/services/studio/shadow_test.go | 4 +- 12 files changed, 293 insertions(+), 77 deletions(-) diff --git a/core/application/addon_gateway.go b/core/application/addon_gateway.go index b08d40216..dc4864dd1 100644 --- a/core/application/addon_gateway.go +++ b/core/application/addon_gateway.go @@ -751,7 +751,7 @@ func (s *studioAddons) Tools(ctx context.Context, user string) ([]studio.AddonTo continue } out = append(out, studio.AddonTool{Name: d.Name, AddonID: d.AddonID, Description: d.Description, Schema: raw, Action: d.Kind != contract.ToolKindRead, - Shadows: d.Shadows, InstalledAt: d.InstalledAt}) + Shadows: d.Shadows, Execution: d.Execution, InstalledAt: d.InstalledAt}) } return filterByFeature(out, s.hostOps, s.has, actor), nil } @@ -777,7 +777,7 @@ func (s *studioAddons) AddonSkills(ctx context.Context, user string) ([]studio.A if denied[a.AddonID] { continue } - out = append(out, studio.AddonSkillSet{AddonID: a.AddonID, Title: a.Title, InstalledAt: a.InstalledAt, Skills: contract.SkillNames(a.Skills), + out = append(out, studio.AddonSkillSet{AddonID: a.AddonID, Title: a.Title, InstalledAt: a.InstalledAt, Skills: contract.SkillNames(a.Skills), Isolated: a.Isolated, Shadows: contract.ShadowedSkills(a.Skills), Running: a.Running}) } return out, nil diff --git a/core/services/addons/tools/adapter.go b/core/services/addons/tools/adapter.go index 628aa873b..421f2eb0c 100644 --- a/core/services/addons/tools/adapter.go +++ b/core/services/addons/tools/adapter.go @@ -113,6 +113,10 @@ type ToolDefinition struct { // contract.ToolDefinition.Shadows), or empty. Only the chat consumer acts // on it; every other consumer keeps the namespaced Name. Shadows string `json:"shadows,omitempty"` + // Execution is what the add-on declares about a tool that shadows bash. It + // is never nil on such a tool: a manifest without a descriptor gets one + // with the add-on's title. + Execution *contract.ExecutionDescriptor `json:"execution,omitempty"` // InstalledAt is when the add-on was first installed (RFC 3339, UTC), or // empty. The chat consumer ranks the add-ons that shadow one name by it. InstalledAt string `json:"installedAt,omitempty"` @@ -400,6 +404,9 @@ type AddonSkills struct { // InstalledAt is when the add-on was first installed, or empty. InstalledAt string Skills []contract.SkillDefinition + // Isolated are the skills that need the add-on's own commands tool to + // report isolated execution (see contract.IsolatedSkills). + Isolated []string // Running is true when the add-on may serve now: enabled and running, as // Discover requires for tools. Running bool @@ -423,7 +430,8 @@ func (t *ToolAdapter) Skills(ctx context.Context, actor contract.Actor) ([]Addon continue } running := a.Enabled && a.State == "running" && a.Generation > 0 - out = append(out, AddonSkills{AddonID: a.ID, Title: a.Title, InstalledAt: a.InstalledAt, Skills: slices.Clone(a.Skills), Running: running}) + out = append(out, AddonSkills{AddonID: a.ID, Title: a.Title, InstalledAt: a.InstalledAt, Skills: slices.Clone(a.Skills), + Isolated: contract.IsolatedSkills(a.ToolDefinitions, a.Skills), Running: running}) } slices.SortFunc(out, func(x, y AddonSkills) int { return strings.Compare(x.AddonID, y.AddonID) }) return out, nil @@ -496,10 +504,27 @@ func definition(a apps.App, d contract.ToolDefinition) (ToolDefinition, bool) { // shadows it. if d.Shadows != "" && contract.ValidateShadow(d) == nil { def.Shadows = d.Shadows + if d.Shadows == "bash" { + def.Execution = executionOf(a, d) + } } return def, true } +// executionOf is the descriptor of a commands tool as the chat consumer sees +// it. A manifest that declares none behaves as a provider of commands under the +// add-on's title. +func executionOf(a apps.App, d contract.ToolDefinition) *contract.ExecutionDescriptor { + e := contract.ExecutionDescriptor{Class: contract.ExecutionClassCommands} + if d.Execution != nil { + e = *d.Execution + } + if e.Title == "" { + e.Title = a.Title + } + return &e +} + // Discover returns the tools the actor may offer a model now, in a stable // order. A model is never told about a tool it could not call. func (t *ToolAdapter) Discover(ctx context.Context, actor contract.Actor) ([]ToolDefinition, error) { diff --git a/core/services/addons/tools/definitions_test.go b/core/services/addons/tools/definitions_test.go index 2fc121f42..ec950778a 100644 --- a/core/services/addons/tools/definitions_test.go +++ b/core/services/addons/tools/definitions_test.go @@ -187,6 +187,38 @@ var _ = Describe("A tool that shadows a built-in chat tool", func() { }) }) +var _ = Describe("The execution descriptor of a commands tool", func() { + ctx := context.Background() + schema := json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}},"required":["command"],"additionalProperties":false}`) + bashTool := func(e *contract.ExecutionDescriptor) contract.ToolDefinition { + return contract.ToolDefinition{Name: "bash", Kind: contract.ToolKindAction, Description: "Run.", InputSchema: schema, Shadows: "bash", Execution: e} + } + discover := func(app apps.App) tools.ToolDefinition { + defs, err := tools.NewChatToolAdapter(&fake{list: apps.List{Apps: []apps.App{app}}}).Discover(ctx, member) + Expect(err).NotTo(HaveOccurred()) + Expect(defs).To(HaveLen(1)) + return defs[0] + } + It("reaches the chat as the add-on declared it", func() { + e := &contract.ExecutionDescriptor{Class: contract.ExecutionClassCommands, Title: "Runbox", Note: "It has jq."} + d := discover(apps.App{ID: "runbox", Title: "Run box", Generation: 1, Enabled: true, State: "running", Tools: []string{"bash"}, ToolDefinitions: []contract.ToolDefinition{bashTool(e)}}) + Expect(d.Execution).To(Equal(e)) + }) + It("falls back to the add-on's title for a manifest that declares none", func() { + d := discover(apps.App{ID: "runbox", Title: "Run box", Generation: 1, Enabled: true, State: "running", Tools: []string{"bash"}, ToolDefinitions: []contract.ToolDefinition{bashTool(nil)}}) + Expect(d.Execution).To(Equal(&contract.ExecutionDescriptor{Class: contract.ExecutionClassCommands, Title: "Run box"})) + }) + It("lists the skills that need isolation for the add-on that owns the commands tool", func() { + skills := []contract.SkillDefinition{{Name: "docx", Shadows: "docx"}, {Name: "runbox-tips"}} + app := apps.App{ID: "runbox", Title: "Run box", Generation: 1, Enabled: true, State: "running", Tools: []string{"bash"}, Skills: skills, + ToolDefinitions: []contract.ToolDefinition{bashTool(nil)}} + list, err := tools.NewChatToolAdapter(&fake{list: apps.List{Apps: []apps.App{app}}}).Skills(ctx, member) + Expect(err).NotTo(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].Isolated).To(Equal([]string{"docx"})) + }) +}) + var _ = Describe("The add-ons that replace a built-in chat tool", func() { ctx := context.Background() bash := json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}},"required":["command"],"additionalProperties":false}`) diff --git a/core/services/studio/addonskills.go b/core/services/studio/addonskills.go index e74a6a94c..2be60b878 100644 --- a/core/services/studio/addonskills.go +++ b/core/services/studio/addonskills.go @@ -38,6 +38,9 @@ type AddonSkillSet struct { // InstalledAt is when the add-on was first installed, or empty. InstalledAt string Skills []string + // Isolated are the skills that run only when the add-on's own commands tool + // reports isolated execution (a subset of Skills). + Isolated []string // Shadows are the built-in skills it replaces (a subset of Skills). Shadows []string // Running is true when the add-on can serve now: installed, enabled and @@ -61,14 +64,15 @@ type addonSkills struct { known []AddonSkillSet // pins are the administrator's choices, by name. pins map[string]string - // hiddenDocuments are the document skills of the Sandbox left out because - // it runs the chat's commands without isolation. + // hiddenDocuments are the document skills left out because the add-on + // that ships them runs the chat's commands without isolation. hiddenDocuments []string - // documentsWhy is why the Sandbox is not known to be isolated. - documentsWhy string + // documentsWhy is why that add-on is not known to be isolated, and + // documentsBy its title. + documentsWhy, documentsBy string } -// withNotes adds to notes the line that says why the Sandbox document skills +// withNotes adds to notes the line that says why the document skills // are missing, so the person is not left to guess. // documentNotePrefix starts the note that explains the missing document skills. const documentNotePrefix = "the document skills " @@ -77,7 +81,7 @@ func (a addonSkills) withNotes(notes []string) []string { if len(a.hiddenDocuments) == 0 { return notes } - return append(notes, documentNotePrefix+strings.Join(a.hiddenDocuments, ", ")+" need an isolated Sandbox; "+a.documentsWhy) + return append(notes, documentNotePrefix+strings.Join(a.hiddenDocuments, ", ")+" need isolated command execution by "+a.documentsBy+"; "+a.documentsWhy) } // addonSkillsFor lists the add-ons of the user that ship skills and decides @@ -118,13 +122,15 @@ func (s *Service) addonSkillsFor(ctx context.Context, p string, addonsOff []stri if gate != nil && gate.Unavailable(ctx, p, set.AddonID, s.d.Files.Available()) != "" { continue } - if set.AddonID == "sandbox" && !sandboxDocumentEligible(command) { - if command.State == CommandsAvailable && command.Provider == "sandbox" { + if len(set.Isolated) > 0 && !isolatedProvider(command, set.AddonID) { + needs := func(n string) bool { return slices.Contains(set.Isolated, n) } + if command.State == CommandsAvailable && command.Provider == set.AddonID { out.documentsWhy = s.documentsWhy(ctx, p, command) - out.hiddenDocuments = append(out.hiddenDocuments, slices.DeleteFunc(slices.Clone(set.Skills), func(n string) bool { return !contract.DocumentSkill(n) })...) + out.documentsBy = command.Title + out.hiddenDocuments = append(out.hiddenDocuments, slices.DeleteFunc(slices.Clone(set.Skills), func(n string) bool { return !needs(n) })...) } - set.Skills = slices.DeleteFunc(slices.Clone(set.Skills), contract.DocumentSkill) - set.Shadows = slices.DeleteFunc(slices.Clone(set.Shadows), contract.DocumentSkill) + set.Skills = slices.DeleteFunc(slices.Clone(set.Skills), needs) + set.Shadows = slices.DeleteFunc(slices.Clone(set.Shadows), needs) } out.usable = append(out.usable, set) } @@ -239,7 +245,7 @@ func (a addonSkills) markFallbacks(list []skills.Skill) string { return "The built-in skills " + strings.Join(parts, ", ") + " serve because the add-on's own skill is off or not running in this chat." } -// documentsWhy says why the Sandbox that runs the chat's commands is not known +// documentsWhy says why the add-on that runs the chat's commands is not known // to be isolated: it runs them on the host, or its status could not be read. func (s *Service) documentsWhy(ctx context.Context, p string, command Commands) string { if command.Isolation == "host" { @@ -247,7 +253,7 @@ func (s *Service) documentsWhy(ctx context.Context, p string, command Commands) } value, d := ObserveExecution(ctx, p, command.Addon) if value.Readiness == "unavailable" { - return "the Sandbox reports that it cannot run commands" + return commandsTitle(command) + " reports that it cannot run commands" } why := d.Failure.Text() if d.Failure == "" { diff --git a/core/services/studio/addontools.go b/core/services/studio/addontools.go index 1edfa140e..2ce347209 100644 --- a/core/services/studio/addontools.go +++ b/core/services/studio/addontools.go @@ -12,6 +12,7 @@ import ( addontools "github.com/mudler/LocalAI/core/services/addons/tools" "github.com/mudler/LocalAI/core/services/studio/session" "github.com/mudler/LocalAI/core/services/studio/workspace" + "github.com/mudler/LocalAI/pkg/addons/contract" ) // AddonTool is one tool of an installed add-on, as a chat offers it to the model. @@ -27,6 +28,9 @@ type AddonTool struct { // empty: the tool keeps its add-on name. A tool that shadows bash is the // add-on's tool that runs shell commands and code. Shadows string + // Execution is what the add-on declares about a tool that shadows bash + // (its provider title and note), or nil. + Execution *contract.ExecutionDescriptor // InstalledAt is when the add-on was first installed (RFC 3339, UTC), or // empty. It decides the default provider of a shadowed name. InstalledAt string diff --git a/core/services/studio/capabilities.go b/core/services/studio/capabilities.go index 27bfd24a5..c32c85f6f 100644 --- a/core/services/studio/capabilities.go +++ b/core/services/studio/capabilities.go @@ -9,10 +9,10 @@ import ( "time" addontools "github.com/mudler/LocalAI/core/services/addons/tools" - "github.com/mudler/LocalAI/core/services/studio/sandbox" "github.com/mudler/LocalAI/core/services/studio/session" "github.com/mudler/LocalAI/core/services/studio/skills" "github.com/mudler/LocalAI/core/services/studio/workspace" + "github.com/mudler/LocalAI/pkg/addons/contract" ) // A chat that has no tool to run commands must say so. Without that fact a @@ -25,7 +25,7 @@ import ( // CommandIsolation is implemented by a broker that can tell how the tool that // runs commands is isolated. It is optional. type CommandIsolation interface { - // Isolation is "isolated" when commands run in a sandbox, "host" when they + // Isolation is "isolated" when commands run in an isolated environment, "host" when they // run directly on the server with no isolation, and "" when the broker // cannot tell. Isolation(ctx context.Context, user, addonID string) string @@ -73,6 +73,12 @@ type Commands struct { // Addon is the add-on that runs commands, or that would (off, // unavailable). Addon string + // Title names the add-on in the text the model and the person read: the + // title its commands tool declares, or the add-on's own. + Title string + // Note is the sentence the add-on declares for the model when its commands + // are isolated, or "". + Note string // Provider serves the tool when State is available: the add-on's ID, or // BuiltinProvider for the built-in bash on this machine. Provider string @@ -132,6 +138,17 @@ func addonRunner(tools []session.Tool) string { return "" } +// commandsDeclared is what the add-on that serves the commands in tools +// declares about its commands tool. +func commandsDeclared(tools []session.Tool, addon string) *contract.ExecutionDescriptor { + for _, t := range tools { + if t.Provider == addon && t.Execution != nil { + return t.Execution + } + } + return nil +} + // standInRunner returns the add-on whose stand-in tool (the add-on cannot // work) is in the list, when that add-on replaces bash. func standInRunner(tools []session.Tool) string { @@ -174,19 +191,47 @@ func (s *Service) commandAddons(ctx context.Context, user string) []string { return OrderProviders(ids, installed) } +// commandTitle is the name of an add-on that is not serving commands now: the +// title of its manifest when the broker lists add-ons in any state, else the +// title its commands tool declares, else its ID. +func (s *Service) commandTitle(ctx context.Context, user, id string) string { + if _, ok := s.d.AddonTools.(ShadowLister); ok { + if t := s.shadowersFor(ctx, user).title[id]; t != "" { + return t + } + } + if s.d.AddonTools == nil { + return id + } + if defs, err := s.d.AddonTools.Tools(ctx, user); err == nil { + for _, d := range defs { + if d.AddonID == id && d.Execution != nil && d.Execution.Title != "" { + return d.Execution.Title + } + } + } + return id +} + // commandsOf says whether the tools of a chat can run commands. addonsOff are // the add-ons the chat switched off and sc the agent's scope, for a chat that // cannot: they tell an add-on that is installed but off from none at all. func (s *Service) commandsOf(ctx context.Context, user string, tools []session.Tool, addonsOff []string, sc *scope) Commands { if addon := addonRunner(tools); addon != "" { - c := Commands{State: CommandsAvailable, Tool: commandTool, Addon: addon} + c := Commands{State: CommandsAvailable, Tool: commandTool, Addon: addon, Title: addon} + if e := commandsDeclared(tools, addon); e != nil { + c.Note = e.Note + if e.Title != "" { + c.Title = e.Title + } + } if iso, ok := s.d.AddonTools.(CommandIsolation); ok { c.Isolation = iso.Isolation(ctx, user, addon) } return c } if addon := standInRunner(tools); addon != "" { - c := Commands{State: CommandsUnavailable, Addon: addon} + c := Commands{State: CommandsUnavailable, Addon: addon, Title: s.commandTitle(ctx, user, addon)} if gate, ok := s.d.AddonTools.(AddonGate); ok { c.Reason = gate.Unavailable(ctx, user, addon, s.d.Files.Available()) } @@ -195,7 +240,7 @@ func (s *Service) commandsOf(ctx context.Context, user string, tools []session.T off := set(addonsOff) for _, id := range s.commandAddons(ctx, user) { if off[id] || (sc != nil && !sc.addons[id]) { - return Commands{State: CommandsOff, Addon: id} + return Commands{State: CommandsOff, Addon: id, Title: s.commandTitle(ctx, user, id)} } } return Commands{State: CommandsNone} @@ -242,13 +287,13 @@ func (s *Service) commandsFor(ctx context.Context, p string, rec session.ChatRec return shellSwitched(s.withHostBash(c, tools), s.hiddenFor(ctx, rec)), nil } -// sandboxWhere is where a command of the Sandbox starts and what it keeps. +// commandsWhere is where a command of an add-on starts and what it keeps. // fileTools says whether the chat has the file tools, which see the same // files: a chat without them must not hear of them. A scoped chat (its agent // works in one folder) has that folder at /workspace/home, at the agent's // mode, and its chat folder at /workspace/chat; its shared folders are read // only. -func sandboxWhere(fileTools bool, place session.Place) string { +func commandsWhere(fileTools bool, place session.Place) string { same := "" if fileTools { same = ", the same files as the file tools" @@ -263,10 +308,18 @@ func sandboxWhere(fileTools bool, place session.Place) string { return "It starts in /workspace/home, the user's home" + same + "; only what it saves under /workspace/home or a writable shared folder is kept." } -// skillsNotInSandbox is the sentence that keeps a model from looking for a -// skill folder in the Sandbox, which sees the user's home only, and points it -// at the tool that loads a skill by its name. -const skillsNotInSandbox = "Skill folders are not visible in the Sandbox: load skills with load_skill." +// skillsNotInProvider is the sentence that keeps a model from looking for a +// skill folder in an isolated environment, which sees the user's home only, and +// points it at the tool that loads a skill by its name. +const skillsNotInProvider = "Skill folders are not visible in the isolated environment: load skills with load_skill." + +// commandsTitle is the name the prompt gives the add-on that runs commands. +func commandsTitle(c Commands) string { + if c.Title != "" { + return c.Title + } + return "the add-on" +} // commandsBlock is the block of the system prompt that states whether the chat // can run commands. writeFile says whether the chat has write_file, @@ -292,11 +345,15 @@ func commandsBlock(c Commands, writeFile, fileTools, skillsOff bool, place sessi case c.Isolation == "isolated": // The turn's note is cut at 400 characters and the tool description // is capped, so the list of programs goes here, in the prompt. - return "Commands: " + c.Tool + " runs commands and code in an isolated sandbox. " + sandboxWhere(fileTools, place) + " " + sandbox.GuestToolsNote + " " + skillsNotInSandbox + b := "Commands: " + c.Tool + " runs commands and code in an isolated environment (" + commandsTitle(c) + "). " + commandsWhere(fileTools, place) + " " + if c.Note != "" { + b += c.Note + " " + } + return b + skillsNotInProvider case c.Isolation == "host": - return "Commands: " + c.Tool + " runs commands directly on the server, with no isolation. Ask before you delete or change anything outside the task. " + sandboxWhere(fileTools, place) + return "Commands: " + c.Tool + " runs commands directly on the server, with no isolation. Ask before you delete or change anything outside the task. " + commandsWhere(fileTools, place) } - return "Commands: " + c.Tool + " runs commands and code. " + sandboxWhere(fileTools, place) + return "Commands: " + c.Tool + " runs commands and code. " + commandsWhere(fileTools, place) } var b strings.Builder b.WriteString("Commands: nothing in this chat runs code or commands. Never claim or imply that you ran anything, and do not ask a sub-agent to run it: it has the same tools. Instead ") @@ -308,11 +365,11 @@ func commandsBlock(c Commands, writeFile, fileTools, skillsOff bool, place sessi case CommandsSwitchedOff: b.WriteString("they switched running commands off for this chat and can switch Run commands on in its Tools menu.") case CommandsOff: - b.WriteString("they can switch the Sandbox add-on on in the Tools menu of this chat.") + b.WriteString("they can switch the " + commandsTitle(c) + " add-on on in the Tools menu of this chat.") case CommandsUnavailable: - b.WriteString("the Sandbox add-on cannot run commands on this server; an administrator can fix that.") + b.WriteString("the " + commandsTitle(c) + " add-on cannot run commands on this server; an administrator can fix that.") default: - b.WriteString("an administrator can enable running code by installing the Sandbox add-on.") + b.WriteString("an administrator can enable running code by installing an add-on that runs commands.") } if skillsOff { b.WriteString(" A skill marked unavailable here needs this: say so instead of starting it.") @@ -426,10 +483,13 @@ func (s *Service) repeatedTask(ctx context.Context, a workspace.Actor, task stri return true, nil } -// sandboxDocumentEligible requires the selected command provider, not just an -// installed guest or generic command authority. Unknown isolation fails closed. -func sandboxDocumentEligible(c Commands) bool { - return c.State == CommandsAvailable && c.Isolation == "isolated" && c.Addon == "sandbox" && c.Provider == "sandbox" +// isolatedProvider reports whether the add-on serves the chat's commands and +// the typed execution status says they are isolated. It is what the skills of +// that add-on that need isolation require: the selected command provider, not +// just an installed guest or generic command authority. Unknown isolation +// fails closed. +func isolatedProvider(c Commands, addon string) bool { + return c.State == CommandsAvailable && c.Isolation == "isolated" && c.Addon == addon && c.Provider == addon } // addonCommandDescriptor describes existing add-on authority without building diff --git a/core/services/studio/capabilities_test.go b/core/services/studio/capabilities_test.go index 39162b35d..7fbe415b5 100644 --- a/core/services/studio/capabilities_test.go +++ b/core/services/studio/capabilities_test.go @@ -15,12 +15,16 @@ import ( "github.com/mudler/LocalAI/core/services/studio/sandbox" "github.com/mudler/LocalAI/core/services/studio/studiotest" "github.com/mudler/LocalAI/core/services/studio/workspace" + "github.com/mudler/LocalAI/pkg/addons/contract" ) +// sandboxExecution is how the Sandbox add-on declares its commands tool. +var sandboxExecution = &contract.ExecutionDescriptor{Class: contract.ExecutionClassCommands, Title: "Sandbox", Note: sandbox.GuestToolsNote} + // runAddon is the Sandbox add-on as the broker lists it: bash runs commands. func runAddon() []studio.AddonTool { bash := addonTool("sandbox", "bash") - bash.Action, bash.Shadows = true, "bash" + bash.Action, bash.Shadows, bash.Execution = true, "bash", sandboxExecution read := addonTool("sandbox", "read") read.Action = true return []studio.AddonTool{bash, read} @@ -76,7 +80,7 @@ var _ = Describe("capabilities", func() { Expect(prompt).To(ContainSubstring("do not ask a sub-agent to run it")) Expect(prompt).To(ContainSubstring("write the output directly with write_file")) Expect(prompt).To(ContainSubstring("give the user the script to run")) - Expect(prompt).To(ContainSubstring("an administrator can enable running code by installing the Sandbox add-on")) + Expect(prompt).To(ContainSubstring("an administrator can enable running code by installing an add-on that runs commands")) Expect(prompt).To(ContainSubstring("A skill marked unavailable here needs this")) Expect(prompt).To(ContainSubstring("- xlsx: Work with xlsx files. (unavailable here: needs command execution)")) Expect(prompt).NotTo(ContainSubstring("/workspace/shared-skills/xlsx/SKILL.md"), "an unavailable skill must not give the path to start it") @@ -106,14 +110,14 @@ var _ = Describe("capabilities", func() { id := create(svc, alice) prompt := turnPrompt(svc, h, id) - Expect(prompt).To(ContainSubstring("Commands: bash runs commands and code in an isolated sandbox.")) + Expect(prompt).To(ContainSubstring("Commands: bash runs commands and code in an isolated environment (Sandbox).")) Expect(prompt).To(ContainSubstring(sandbox.GuestToolsNote), "the isolated sandbox lists its programs") - Expect(prompt).To(ContainSubstring("Skill folders are not visible in the Sandbox: load skills with load_skill.")) + Expect(prompt).To(ContainSubstring("Skill folders are not visible in the isolated environment: load skills with load_skill.")) Expect(prompt).NotTo(ContainSubstring("nothing in this chat runs code")) Expect(prompt).To(ContainSubstring("\n- xlsx: ")) Expect(prompt).NotTo(ContainSubstring("/workspace/shared-skills/xlsx/SKILL.md"), "the index names no path") Expect(prompt).NotTo(ContainSubstring("unavailable here")) - Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsAvailable, Tool: "bash", Isolation: "isolated", Addon: "sandbox", Provider: "sandbox"})) + Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsAvailable, Tool: "bash", Isolation: "isolated", Addon: "sandbox", Provider: "sandbox", Title: "Sandbox", Note: sandbox.GuestToolsNote})) }) It("warns when commands run on the server with no isolation", func() { @@ -133,7 +137,7 @@ var _ = Describe("capabilities", func() { prompt := turnPrompt(svc, h, id) Expect(prompt).To(ContainSubstring("nothing in this chat runs code or commands")) Expect(prompt).To(ContainSubstring("they can switch the Sandbox add-on on in the Tools menu of this chat")) - Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsOff, Addon: "sandbox"})) + Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsOff, Addon: "sandbox", Title: "Sandbox"})) }) It("says the Sandbox cannot run when no backend works", func() { @@ -143,7 +147,7 @@ var _ = Describe("capabilities", func() { id := create(svc, alice) prompt := turnPrompt(svc, h, id) Expect(prompt).To(ContainSubstring("the Sandbox add-on cannot run commands on this server")) - Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsUnavailable, Reason: "no sandbox backend works on this host", Addon: "sandbox"})) + Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsUnavailable, Reason: "no sandbox backend works on this host", Addon: "sandbox", Title: "Sandbox"})) }) It("does not call the add-on off for a user who cannot use it", func() { @@ -278,3 +282,44 @@ var _ = Describe("execution presentation DTO", func() { } }) }) + +var _ = Describe("a second provider of commands", func() { + runbox := func() []studio.AddonTool { + bash := addonTool("runbox", "bash") + bash.Action, bash.Shadows = true, "bash" + bash.Execution = &contract.ExecutionDescriptor{Class: contract.ExecutionClassCommands, Title: "Runbox", Note: "Runbox has python3 and jq."} + return []studio.AddonTool{bash} + } + It("is described by the title and the note it declares, not by a name known to the host", func() { + fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: runbox()}} + svc, h, _ := newCapsChat(isolatedAddons{fakeAddons: fa, mode: "isolated"}, nil) + id := create(svc, alice) + prompt := turnPrompt(svc, h, id) + Expect(prompt).To(ContainSubstring("Commands: bash runs commands and code in an isolated environment (Runbox).")) + Expect(prompt).To(ContainSubstring("Runbox has python3 and jq.")) + Expect(prompt).NotTo(ContainSubstring("Sandbox")) + Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsAvailable, Tool: "bash", Isolation: "isolated", Addon: "runbox", Provider: "runbox", Title: "Runbox", Note: "Runbox has python3 and jq."})) + }) + It("is named when the chat switched it off and when it cannot run", func() { + fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: runbox()}} + svc, h, _ := newCapsChat(fa, nil) + id := create(svc, alice) + Expect(svc.SetAddonsOff(context.Background(), alice, id, []string{"runbox"})).To(Succeed()) + Expect(turnPrompt(svc, h, id)).To(ContainSubstring("they can switch the Runbox add-on on in the Tools menu of this chat")) + + gate := gatedAddons{fakeAddons: fa, reasons: map[string]string{"runbox": "no backend works"}} + svc, h, _ = newCapsChat(gate, nil) + id = create(svc, alice) + prompt := turnPrompt(svc, h, id) + Expect(prompt).To(ContainSubstring("the Runbox add-on cannot run commands on this server")) + Expect(prompt).NotTo(ContainSubstring("Sandbox")) + }) + It("behaves as an unnamed provider when its manifest declares no descriptor", func() { + bash := addonTool("runbox", "bash") + bash.Action, bash.Shadows = true, "bash" + fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: {bash}}} + svc, h, _ := newCapsChat(isolatedAddons{fakeAddons: fa, mode: ""}, nil) + id := create(svc, alice) + Expect(turnPrompt(svc, h, id)).To(ContainSubstring("Commands: bash runs commands and code.")) + }) +}) diff --git a/core/services/studio/documenteligibility_internal_test.go b/core/services/studio/documenteligibility_internal_test.go index 0cb4ae88d..4f61cdaac 100644 --- a/core/services/studio/documenteligibility_internal_test.go +++ b/core/services/studio/documenteligibility_internal_test.go @@ -13,6 +13,7 @@ import ( "github.com/mudler/LocalAI/core/services/studio/studiotest" "github.com/mudler/LocalAI/core/services/studio/skills" + "github.com/mudler/LocalAI/pkg/addons/contract" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -23,7 +24,7 @@ var _ = Describe("document command eligibility", func() { b := &skillBroker{} svc, f := newSkillService(b) Expect(f.SyncAddonSkills(ctx, "sandbox", fstest.MapFS{"docx/SKILL.md": {Data: md("docx", "Sandbox document.")}})).To(Succeed()) - b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx"}, Shadows: []string{"docx"}, Running: true}) + b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx"}, Shadows: []string{"docx"}, Isolated: []string{"docx"}, Running: true}) list, _, err := svc.Skills(ctx, "alice", "") Expect(err).NotTo(HaveOccurred()) Expect(skills.Names(skills.Usable(list))).NotTo(ContainElement("docx")) @@ -37,21 +38,44 @@ var _ = Describe("document command eligibility", func() { "pdf/SKILL.md": {Data: md("pdf", "Sandbox pdf.")}, "plot/SKILL.md": {Data: md("plot", "Sandbox plots.")}, })).To(Succeed()) - b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx", "pdf", "plot"}, Shadows: []string{"docx", "pdf"}, Running: true}) + b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx", "pdf", "plot"}, Shadows: []string{"docx", "pdf"}, Isolated: []string{"docx", "pdf"}, Running: true}) _, notes, err := chatSkillNotes(svc, ctx, off, command) Expect(err).NotTo(HaveOccurred()) if wantNote { - Expect(notes).To(ContainElement(HavePrefix("the document skills docx, pdf need an isolated Sandbox; "))) + Expect(notes).To(ContainElement(HavePrefix("the document skills docx, pdf need isolated command execution by Sandbox; "))) } else { - Expect(strings.Join(notes, ";")).NotTo(ContainSubstring("isolated Sandbox")) + Expect(strings.Join(notes, ";")).NotTo(ContainSubstring("isolated command execution")) } }, - Entry("commands run on the host", Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Isolation: "host"}, nil, true), - Entry("isolation unknown", Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox"}, nil, true), + Entry("commands run on the host", Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Title: "Sandbox", Isolation: "host"}, nil, true), + Entry("isolation unknown", Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Title: "Sandbox"}, nil, true), Entry("isolated", Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Isolation: "isolated"}, nil, false), Entry("another provider runs the commands", Commands{State: CommandsAvailable, Addon: "other", Provider: "other", Isolation: "host"}, nil, false), Entry("the Sandbox is off for the chat", Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Isolation: "host"}, []string{"sandbox"}, false), ) + It("names the second provider by the title it declares, in the note and in the commands", func() { + ctx := context.Background() + b := &documentBroker{skillBroker: &skillBroker{}, isolation: "host", defs: []AddonTool{{AddonID: "runbox", Name: "addon__runbox__exec", Shadows: "bash", + Execution: &contract.ExecutionDescriptor{Class: contract.ExecutionClassCommands, Title: "Runbox"}}}} + svc, f := newSkillService(b.skillBroker) + svc.d.AddonTools = b + Expect(f.SyncAddonSkills(ctx, "runbox", fstest.MapFS{"docx/SKILL.md": {Data: md("docx", "Runbox document.")}})).To(Succeed()) + b.set(AddonSkillSet{AddonID: "runbox", Skills: []string{"docx"}, Shadows: []string{"docx"}, Isolated: []string{"docx"}, Running: true}) + rec, err := svc.Create(ctx, "alice", CreateInput{Model: "m"}) + Expect(err).NotTo(HaveOccurred()) + command := documentCommands(svc, ctx, "alice", rec, nil) + Expect(command.Title).To(Equal("Runbox")) + Expect(isolatedProvider(command, "runbox")).To(BeFalse(), "the status says host") + _, notes, err := chatSkillNotes(svc, ctx, nil, command) + Expect(err).NotTo(HaveOccurred()) + Expect(notes).To(ContainElement(ContainSubstring("need isolated command execution by Runbox; its commands run on the server without isolation"))) + b.isolation = "isolated" + command = documentCommands(svc, ctx, "alice", rec, nil) + Expect(isolatedProvider(command, "runbox")).To(BeTrue()) + list, _, err := chatSkillNotes(svc, ctx, nil, command) + Expect(err).NotTo(HaveOccurred()) + Expect(skills.Names(skills.Usable(list))).To(ContainElement("docx")) + }) It("does not switch a missing pinned document provider to Office", func() { a := addonSkills{pins: map[string]string{"docx": "sandbox"}, usable: []AddonSkillSet{{AddonID: "office", Shadows: []string{"docx"}}}} id, chosen := a.provider("docx") @@ -60,6 +84,9 @@ var _ = Describe("document command eligibility", func() { }) }) +// sandboxExecution is how the Sandbox add-on declares its commands tool. +var sandboxExecution = &contract.ExecutionDescriptor{Class: contract.ExecutionClassCommands, Title: "Sandbox"} + // This broker lists metadata but fails immediately if descriptor construction // invokes a command. Keeping it separate avoids changing older fixtures. type documentBroker struct { @@ -88,7 +115,11 @@ var _ = Describe("document provider matrix", func() { svc, f := newSkillService(b, map[string]string{"docx": pin}) for _, id := range ids { Expect(f.SyncAddonSkills(ctx, id, fstest.MapFS{"docx/SKILL.md": {Data: md("docx", id+" document.")}})).To(Succeed()) - b.sets = append(b.sets, AddonSkillSet{AddonID: id, Skills: []string{"docx"}, Shadows: []string{"docx"}, Running: true}) + set := AddonSkillSet{AddonID: id, Skills: []string{"docx"}, Shadows: []string{"docx"}, Running: true} + if id == "sandbox" || id == "runbox" { // office ships no commands tool, so its document skill needs no isolation + set.Isolated = []string{"docx"} + } + b.sets = append(b.sets, set) } list, _, _, err := svc.chatSkillSet(ctx, "alice", nil, off, sc, command) Expect(err).NotTo(HaveOccurred()) @@ -114,34 +145,38 @@ var _ = Describe("document provider matrix", func() { Entry("unavailable pin", []string{"sandbox", "office"}, Commands{}, nil, nil, "sandbox", ""), Entry("missing pin", []string{"office"}, Commands{}, nil, nil, "sandbox", ""), Entry("eligible pin", []string{"sandbox", "office"}, Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Isolation: "isolated"}, nil, nil, "sandbox", "sandbox"), + Entry("a second provider selected", []string{"runbox"}, Commands{State: CommandsAvailable, Addon: "runbox", Provider: "runbox", Isolation: "isolated"}, nil, nil, "", "runbox"), + Entry("a second provider on the host", []string{"runbox"}, Commands{State: CommandsAvailable, Addon: "runbox", Provider: "runbox", Isolation: "host"}, nil, nil, "", ""), + Entry("a second provider with unknown isolation", []string{"runbox"}, Commands{State: CommandsAvailable, Addon: "runbox", Provider: "runbox"}, nil, nil, "", ""), + Entry("the other provider runs the commands", []string{"runbox"}, Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Isolation: "isolated"}, nil, nil, "", ""), Entry("existing default tie", []string{"sandbox", "office"}, Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Isolation: "isolated"}, nil, nil, "", "office"), ) It("describes selected commands without recursive tool building or execution, including subagent final pruning", func() { ctx := context.Background() svc, _ := newSkillService(&skillBroker{}) - b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash", Schema: json.RawMessage(`{"type":"object"}`)}}} + b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash", Execution: sandboxExecution, Schema: json.RawMessage(`{"type":"object"}`)}}} svc.d.AddonTools = b rec, err := svc.Create(ctx, "alice", CreateInput{Model: "m"}) Expect(err).NotTo(HaveOccurred()) c := documentCommands(svc, ctx, "alice", rec, nil) - Expect(sandboxDocumentEligible(c)).To(BeTrue()) + Expect(isolatedProvider(c, "sandbox")).To(BeTrue()) Expect(b.calls).To(BeNumerically("<=", 3)) rec.Kind = session.KindSubAgent - Expect(sandboxDocumentEligible(documentCommands(svc, ctx, "alice", rec, nil))).To(BeFalse()) + Expect(isolatedProvider(documentCommands(svc, ctx, "alice", rec, nil), "sandbox")).To(BeFalse()) svc.allowed[key("alice", rec.ID)] = []session.Tool{{Name: "bash", Provider: "sandbox"}} - Expect(sandboxDocumentEligible(documentCommands(svc, ctx, "alice", rec, nil))).To(BeTrue()) - Expect(sandboxDocumentEligible(documentCommands(svc, ctx, "alice", rec, &scope{deleted: true}))).To(BeFalse()) + Expect(isolatedProvider(documentCommands(svc, ctx, "alice", rec, nil), "sandbox")).To(BeTrue()) + Expect(isolatedProvider(documentCommands(svc, ctx, "alice", rec, &scope{deleted: true}), "sandbox")).To(BeFalse()) }) DescribeTable("uses saved-agent subagent grants for the index and loader", func(granted bool) { ctx := context.Background() - b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash"}}} + b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash", Execution: sandboxExecution}}} svc, f := newSkillService(b.skillBroker) svc.d.AddonTools = b agentsStore, err := agents.NewJSON("") Expect(err).NotTo(HaveOccurred()) svc.d.Agents = agentsStore Expect(f.SyncAddonSkills(ctx, "sandbox", fstest.MapFS{"docx/SKILL.md": {Data: skillMD("docx", "Sandbox document.", "Sandbox body.", "requires: [exec]")}})).To(Succeed()) - b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx"}, Shadows: []string{"docx"}, Running: true}) + b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx"}, Shadows: []string{"docx"}, Isolated: []string{"docx"}, Running: true}) grants := []string{} if granted { grants = []string{"sandbox"} @@ -200,14 +235,14 @@ var _ = Describe("document provider matrix", func() { It("says why a document skill is missing when the status cannot be read after the tool was offered", func() { ctx := context.Background() - b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash"}}} + b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash", Execution: sandboxExecution}}} svc, f := newSkillService(b.skillBroker) svc.d.AddonTools = b agentsStore, err := agents.NewJSON("") Expect(err).NotTo(HaveOccurred()) svc.d.Agents = agentsStore Expect(f.SyncAddonSkills(ctx, "sandbox", fstest.MapFS{"pptx/SKILL.md": {Data: skillMD("pptx", "Sandbox slides.", "Slides body.", "requires: [exec]")}})).To(Succeed()) - b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"pptx"}, Shadows: []string{"pptx"}, Running: true}) + b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"pptx"}, Shadows: []string{"pptx"}, Isolated: []string{"pptx"}, Running: true}) agent, err := svc.SaveAgent(ctx, "alice", agents.Agent{Name: "Slides", Model: "m", Addons: []string{"sandbox"}, Skills: []string{"pptx"}}) Expect(err).NotTo(HaveOccurred()) parent, err := svc.Create(ctx, "alice", CreateInput{Model: "m"}) @@ -230,7 +265,7 @@ var _ = Describe("document provider matrix", func() { _, err = loader.Run(ctx, json.RawMessage(`{"name":"pptx"}`)) Expect(err).To(HaveOccurred()) Expect(err.Error()).NotTo(ContainSubstring("has no skills now")) - Expect(err.Error()).To(ContainSubstring("need an isolated Sandbox; its status could not be read")) + Expect(err.Error()).To(ContainSubstring("need isolated command execution by Sandbox; its status could not be read")) }) It("opens a scoped chat with load_skill without reentering session access resolution", func() { ctx := context.Background() @@ -276,30 +311,30 @@ var _ = Describe("document provider matrix", func() { It("refuses another user's chat and access errors rather than using the whole home", func() { ctx := context.Background() svc, _ := newSkillService(&skillBroker{}) - b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash"}}} + b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash", Execution: sandboxExecution}}} svc.d.AddonTools = b rec, err := svc.Create(ctx, "alice", CreateInput{Model: "m"}) Expect(err).NotTo(HaveOccurred()) c, err := svc.addonCommandDescriptor(ctx, "bob", rec, nil) Expect(err).To(HaveOccurred()) - Expect(sandboxDocumentEligible(c)).To(BeFalse()) + Expect(isolatedProvider(c, "sandbox")).To(BeFalse()) Expect(b.calls).To(BeZero()) rec.ID = "missing" c, err = svc.addonCommandDescriptor(ctx, "alice", rec, nil) Expect(err).To(HaveOccurred()) - Expect(sandboxDocumentEligible(c)).To(BeFalse()) + Expect(isolatedProvider(c, "sandbox")).To(BeFalse()) Expect(b.calls).To(BeZero()) // Only the genuinely chatless picker may describe person capabilities. - Expect(sandboxDocumentEligible(documentCommands(svc, ctx, "alice", session.ChatRecord{}, nil))).To(BeTrue()) + Expect(isolatedProvider(documentCommands(svc, ctx, "alice", session.ChatRecord{}, nil), "sandbox")).To(BeTrue()) }) It("keeps picker, slash selection, index and fresh loads aligned across readiness and final pruning", func() { ctx := context.Background() - b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash"}}} + b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash", Execution: sandboxExecution}}} svc, f := newSkillService(b.skillBroker) svc.d.AddonTools = b Expect(f.SyncAddonSkills(ctx, "sandbox", fstest.MapFS{"docx/SKILL.md": {Data: skillMD("docx", "Sandbox document.", "Sandbox body.", "requires: [exec]")}})).To(Succeed()) - b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx"}, Shadows: []string{"docx"}, Running: true}) + b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx"}, Shadows: []string{"docx"}, Isolated: []string{"docx"}, Running: true}) rec, err := svc.Create(ctx, "alice", CreateInput{Model: "m"}) Expect(err).NotTo(HaveOccurred()) a := userChatActor("alice", rec.ID) @@ -341,7 +376,7 @@ var _ = Describe("document provider matrix", func() { svc.d.ToolPins = func(context.Context) (map[string]string, error) { return nil, unavailable } command, err := svc.addonCommandDescriptor(ctx, "alice", rec, nil) Expect(errors.Is(err, unavailable)).To(BeTrue()) - Expect(sandboxDocumentEligible(command)).To(BeFalse()) + Expect(isolatedProvider(command, "sandbox")).To(BeFalse()) _, err = svc.CommandRunner(ctx, "alice", rec.ID) Expect(errors.Is(err, unavailable)).To(BeTrue()) _, _, err = svc.Skills(ctx, "alice", rec.ID) diff --git a/core/services/studio/loadskill.go b/core/services/studio/loadskill.go index 1e7c3c991..44830a489 100644 --- a/core/services/studio/loadskill.go +++ b/core/services/studio/loadskill.go @@ -43,8 +43,12 @@ type loadOpts struct { files bool // exec is true when the chat can run commands. A skill that requires // them is refused with the reason when it cannot. - exec bool - sandbox *bool + exec bool + // provided is true when an add-on, not the built-in bash, serves the + // chat's commands in the final tool list. Without it the commands count as + // absent when the skills resolve, so a skill that needs an add-on's + // isolation is not loaded on the strength of a stale descriptor. + provided *bool } // registerLoadSkill adds load_skill to the registry of built-in tools. It has @@ -96,8 +100,8 @@ func (s *Service) fitLoadSkill(a workspace.Actor, tools []session.Tool) []sessio return tools } // Final tools may only narrow the freshly checked descriptor at call time. - sandbox := addonRunner(tools) == "sandbox" - tools[i].Run = s.loadSkillRun(a, loadOpts{files: hasTool(tools, "read_file"), exec: s.runsCommands(tools), sandbox: &sandbox}) + provided := addonRunner(tools) != "" + tools[i].Run = s.loadSkillRun(a, loadOpts{files: hasTool(tools, "read_file"), exec: s.runsCommands(tools), provided: &provided}) return tools } @@ -160,12 +164,12 @@ func (s *Service) loadSkillRun(a workspace.Actor, o loadOpts) func(context.Conte if err != nil { return "", err } - list, notes, err := s.loadableSkillsAndNotes(ctx, a, o.sandbox) + list, notes, err := s.loadableSkillsAndNotes(ctx, a, o.provided) if err != nil { return "", fmt.Errorf("the skills cannot be read now: %w", err) } // The tool was offered because the chat had this skill; a document - // skill is missing now only because the Sandbox is not known to be + // skill is missing now only because its add-on is not known to be // isolated, and the model must say so rather than "no skills". if _, ok := skills.Find(list, in.Name); !ok && contract.DocumentSkill(skills.Clean(in.Name)) { if i := slices.IndexFunc(notes, func(n string) bool { return strings.HasPrefix(n, documentNotePrefix) }); i >= 0 { diff --git a/core/services/studio/session/harness.go b/core/services/studio/session/harness.go index 93825e5af..95bddabd1 100644 --- a/core/services/studio/session/harness.go +++ b/core/services/studio/session/harness.go @@ -3,7 +3,9 @@ package session import ( "context" "encoding/json" + "github.com/mudler/LocalAI/core/services/studio/workspace" + "github.com/mudler/LocalAI/pkg/addons/contract" ) // Harness starts agent sessions. @@ -78,6 +80,9 @@ type Tool struct { // say): "builtin" or the add-on's ID. It is empty for every other tool. // The harness does not read it. Provider string + // Execution is what the add-on that serves a command tool declares about + // itself, or nil. The harness does not read it. + Execution *contract.ExecutionDescriptor // StandInFor is set on the stand-in tool of an add-on that cannot work // now: the built-in tools that add-on replaces (bash, say), so the chat // can tell that the built-in serves in its place. The harness does not diff --git a/core/services/studio/shadow.go b/core/services/studio/shadow.go index a07288e2e..04e6bacd9 100644 --- a/core/services/studio/shadow.go +++ b/core/services/studio/shadow.go @@ -56,7 +56,7 @@ func (s *Service) shadowTool(a workspace.Actor, d AddonTool, schema json.RawMess // What the command saved in the user's files goes on its card. run = s.withCommandFiles(a, run) } - return session.Tool{Name: d.Shadows, Description: d.Description, Schema: schema, Provider: addon, Run: run}, true + return session.Tool{Name: d.Shadows, Description: d.Description, Schema: schema, Provider: addon, Execution: d.Execution, Run: run}, true } // withBuiltins adds the built-in tools that no add-on shadows. A built-in whose diff --git a/core/services/studio/shadow_test.go b/core/services/studio/shadow_test.go index 3d2839c65..e53a18101 100644 --- a/core/services/studio/shadow_test.go +++ b/core/services/studio/shadow_test.go @@ -15,7 +15,7 @@ import ( // bash, which shadows the built-in bash, and status under its add-on name. func shadowingSandbox() []studio.AddonTool { bash := addonTool("sandbox", "bash") - bash.Action, bash.Shadows = true, "bash" + bash.Action, bash.Shadows, bash.Execution = true, "bash", sandboxExecution return []studio.AddonTool{bash, addonTool("sandbox", "status")} } @@ -105,6 +105,6 @@ var _ = Describe("an add-on tool that shadows a built-in", func() { fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: shadowingSandbox()}} svc, h, _ := newCapsChat(isolatedAddons{fa, "isolated"}, nil) id := create(svc, alice) - Expect(turnPrompt(svc, h, id)).To(ContainSubstring("Commands: bash runs commands and code in an isolated sandbox.")) + Expect(turnPrompt(svc, h, id)).To(ContainSubstring("Commands: bash runs commands and code in an isolated environment (Sandbox).")) }) }) From a7771d06cfac09c5ff34bb120499300938655ae0 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 09:53:53 +0000 Subject: [PATCH 3/5] feat(sandbox): declare the execution descriptor in the manifest The Sandbox add-on states in its manifest that its bash tool runs commands, under which title, with which note for the model, and that it supplies the runtime of the document skills. The manifest accepts API 1 to 2. A spec checks that the manifest matches the descriptor in code. A guard spec fails when the studio package or the add-on tool adapter compares a string with the ID of the Sandbox again, or when the files that build the commands text name it. The contracts suite inventory lists the new specs, and two specs of the network-policy status that it was missing. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto --- addons/sandbox/internal/tools/tools.go | 14 +++++ addons/sandbox/internal/tools/tools_test.go | 2 + addons/sandbox/manifest.json | 14 ++++- core/services/studio/noaddonid_test.go | 70 +++++++++++++++++++++ scripts/addons/suites.json | 12 +++- 5 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 core/services/studio/noaddonid_test.go diff --git a/addons/sandbox/internal/tools/tools.go b/addons/sandbox/internal/tools/tools.go index c763eab1b..9955e86d0 100644 --- a/addons/sandbox/internal/tools/tools.go +++ b/addons/sandbox/internal/tools/tools.go @@ -19,6 +19,7 @@ import ( "github.com/mudler/LocalAI/addons/internal/protocol" "github.com/mudler/LocalAI/addons/sandbox/internal/backends" + "github.com/mudler/LocalAI/core/services/studio/sandbox" "github.com/mudler/LocalAI/core/services/studio/sandbox/runner" "github.com/mudler/LocalAI/core/services/studio/workspace/workfiles" "github.com/mudler/LocalAI/pkg/addons/contract" @@ -115,6 +116,18 @@ func (r *runtime) Close(ctx context.Context) error { var errNoTool = errors.New("this tool is not served by the sandbox add-on") +// Execution is what the add-on declares about its bash tool: the host names the +// provider by this title, tells the model the note when commands are isolated, +// and offers the document skills of this package only while the status says so. +func Execution() *contract.ExecutionDescriptor { + return &contract.ExecutionDescriptor{ + Class: contract.ExecutionClassCommands, + Title: "Sandbox", + Note: sandbox.GuestToolsNote, + Skills: &contract.ExecutionSkills{ProvidesRuntimeFor: []string{contract.SkillKindDocuments}}, + } +} + // Definitions returns what a model sees of each tool. They come from the code // tools themselves, with the strictness the contract asks of an arguments // schema. @@ -133,6 +146,7 @@ func Definitions() []contract.ToolDefinition { def := contract.ToolDefinition{Name: t.Name, Kind: contract.ToolKindAction, Description: t.Description, InputSchema: raw} if t.Name == ShadowedTool { def.Shadows = ShadowedTool + def.Execution = Execution() } out = append(out, def) } diff --git a/addons/sandbox/internal/tools/tools_test.go b/addons/sandbox/internal/tools/tools_test.go index f191f62e1..a51cd3ff5 100644 --- a/addons/sandbox/internal/tools/tools_test.go +++ b/addons/sandbox/internal/tools/tools_test.go @@ -261,6 +261,7 @@ var _ = Describe("Sandbox tools", func() { // add-on's own page, with the default grant of egress.check. Expect(m.Capabilities.UI.Controls).To(Equal([]string{contract.ControlNetworkPolicy, contract.ControlToolProviders})) Expect(contract.EntitledControls(m, []string{"workspace.", "egress."})).To(Equal(m.Capabilities.UI.Controls)) + Expect(m.API.Max).To(BeNumerically(">=", contract.MinExecutionAPI), "the execution descriptor needs api 2") want := tools.Definitions() Expect(m.Capabilities.ToolDefinitions).To(HaveLen(len(want))) for i, d := range m.Capabilities.ToolDefinitions { @@ -272,6 +273,7 @@ var _ = Describe("Sandbox tools", func() { Expect(d.Description).To(Equal(want[i].Description), "definition %s differs from the runtime", d.Name) Expect(d.UIOnly).To(Equal(want[i].UIOnly), "definition %s differs from the runtime", d.Name) Expect(d.Shadows).To(Equal(want[i].Shadows), "definition %s differs from the runtime", d.Name) + Expect(d.Execution).To(Equal(want[i].Execution), "definition %s differs from the runtime", d.Name) Expect(a).To(Equal(b), "definition %s differs from the runtime", d.Name) Expect(contract.ValidateToolSchema(d.InputSchema, d.Kind)).To(Succeed(), "schema of %s", d.Name) } diff --git a/addons/sandbox/manifest.json b/addons/sandbox/manifest.json index b698af0ec..4059cfe41 100644 --- a/addons/sandbox/manifest.json +++ b/addons/sandbox/manifest.json @@ -1,6 +1,6 @@ { "api": { - "max": 1, + "max": 2, "min": 1 }, "arch": "amd64", @@ -41,7 +41,17 @@ ], "type": "object" }, - "shadows": "bash" + "shadows": "bash", + "execution": { + "class": "commands", + "title": "Sandbox", + "note": "The sandbox has: python3 (pandas, openpyxl, docx, pptx, pypdf, Pillow), pip, jq, ripgrep (rg), sqlite3, zip, unzip, file, tree, poppler (pdftotext, pdftoppm), git, curl. It has no Node, ImageMagick, ffmpeg or OCR, and apt cannot install anything.", + "skills": { + "providesRuntimeFor": [ + "documents" + ] + } + } }, { "name": "execution-status", diff --git a/core/services/studio/noaddonid_test.go b/core/services/studio/noaddonid_test.go new file mode 100644 index 000000000..9128fdd80 --- /dev/null +++ b/core/services/studio/noaddonid_test.go @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MIT +package studio_test + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Core decides what an add-on may do from what the add-on declares and what it +// reports at run time. These specs fail when a file that sets the rules for +// command execution names one add-on again. Tests, comments, documentation and +// the add-on's own data are the places that may. +var _ = Describe("the command execution code of the host", func() { + // literals returns the string literals of the non-test Go files of dir. + literals := func(dir string, only ...string) map[string][]string { + out := map[string][]string{} + entries, err := os.ReadDir(dir) + Expect(err).NotTo(HaveOccurred()) + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + if len(only) > 0 && !slices.Contains(only, name) { + continue + } + path := filepath.Join(dir, name) + file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + Expect(err).NotTo(HaveOccurred()) + ast.Inspect(file, func(n ast.Node) bool { + if lit, ok := n.(*ast.BasicLit); ok && lit.Kind == token.STRING { + v, err := strconv.Unquote(lit.Value) + Expect(err).NotTo(HaveOccurred()) + out[path] = append(out[path], v) + } + return true + }) + } + return out + } + + It("compares no string with the ID of the add-on that runs commands", func() { + // Other add-on IDs join this list as their special cases are removed. + ids := []string{"sandbox"} + for _, dir := range []string{".", "../addons/tools"} { + for path, values := range literals(dir) { + for _, v := range values { + Expect(ids).NotTo(ContainElement(v), "%s compares with the ID of an add-on", path) + } + } + } + }) + + It("names no add-on in the text it builds for the commands of a chat", func() { + for path, values := range literals(".", "capabilities.go", "addonskills.go", "loadskill.go", "shadow.go") { + for _, v := range values { + Expect(strings.ToLower(v)).NotTo(ContainSubstring("sandbox"), "%s writes a provider name into a sentence; take it from the manifest", path) + } + } + }) +}) diff --git a/scripts/addons/suites.json b/scripts/addons/suites.json index e74c3959a..4171ebfcf 100644 --- a/scripts/addons/suites.json +++ b/scripts/addons/suites.json @@ -383,7 +383,17 @@ "an unknown delivery", "a page the UI does not declare", "a preview interval too short", - "a preview interval too long" + "a preview interval too long", + "is valid for a tool that shadows bash when the manifest accepts API 2", + "needs the manifest to accept API 2", + "is refused on a tool that does not shadow bash, with another class or with bad text", + "keeps a manifest of API 1 valid on a host that speaks API 2, and refuses one that needs API 3", + "names the document skills of a provider that declares it supplies their runtime", + "fails closed for a commands tool without a descriptor", + "names nothing for a descriptor that does not supply the runtime of documents", + "names nothing for an add-on without a commands tool", + "reports network-policy enforcement only for an available isolated provider", + "refuses a non-boolean enforcement claim" ] }, { From d1234352086d60fc48c9db1c99068617911901a2 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 09:55:04 +0000 Subject: [PATCH 4/5] docs(addons): document the execution descriptor of a commands tool Describe the descriptor for add-on authors: its fields, what the host does with them, the API 2 requirement and the behavior of a manifest that declares none. Add the new fields to the generated API docs. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto --- docs/content/features/addons.md | 50 ++++++++++++++++++++++++++++++++- swagger/docs.go | 45 +++++++++++++++++++++++++++++ swagger/swagger.json | 45 +++++++++++++++++++++++++++++ swagger/swagger.yaml | 38 +++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 1 deletion(-) diff --git a/docs/content/features/addons.md b/docs/content/features/addons.md index 52a6514a8..ec69a7b15 100644 --- a/docs/content/features/addons.md +++ b/docs/content/features/addons.md @@ -139,7 +139,8 @@ and reconciling the operation before further use. ## Optional typed manifest extensions These declarations extend the strict manifest contract. They keep -`schemaVersion: 1`, host/API/protocol/data-format level 1 and UI SDK 2. +`schemaVersion: 1`, host/protocol/data-format level 1 and UI SDK 2. The manifest +`api` range is 1 to 2: only the execution descriptor below needs 2. The Go contract parser is authoritative; there is no separate JSON Schema. The fragments below merge into an otherwise valid manifest, not a complete installable package. Existing executable, identity, compatibility and UI @@ -287,6 +288,53 @@ list and rejects its pending responses, even before effect cleanup. A new list request can populate the refreshed session. These in-memory scopes contain no credentials. +### Execution descriptor of a commands tool + +Needs manifest `api` 2. On the definition of a tool that shadows `bash`: + +```json +{ + "name": "bash", + "kind": "action", + "shadows": "bash", + "description": "...", + "inputSchema": { "...": "..." }, + "execution": { + "class": "commands", + "title": "Runbox", + "note": "The environment has python3, jq and sqlite3.", + "skills": { "providesRuntimeFor": ["documents"] } + } +} +``` + +The descriptor says what the add-on is, so that the host can write about it +without knowing its ID. It grants no authority. Whether commands are ready and +isolated stays what the provider reports in its execution status (see above); +the host decides from that report. + +| Field | Meaning | +|---|---| +| `class` | Required. `commands`: the tool runs shell commands and code. | +| `title` | Optional, at most 40 characters of plain text. The host calls the provider by this name in the prompt and in the Tools menu text ("Runbox runs commands in an isolated environment", "switch the Runbox add-on on"). Without it, the host uses the add-on's own title. | +| `note` | Optional, at most 600 characters of plain text. One sentence for the model, such as the programs the environment has. The host adds it to the Commands block only while the provider reports `isolated`. | +| `skills.providesRuntimeFor` | Optional list of skill kinds. `documents` means the add-on's document skills (`xlsx`, `docx`, `pptx`, `pdf`) run on this provider. | + +The host offers the document skills of an add-on only while that add-on serves +the chat's commands and its status reports `isolated`. When the status is +`host` or unknown, or another provider serves the commands, the skills are left +out and the chat says why. A tool descriptor may leave `providesRuntimeFor` +out to opt out of that check, because its skills do not need its commands. + +The field is optional and does not change the protocol. A manifest that +declares a descriptor must accept API 2 (`"api": {"min": 1, "max": 2}`). The +host speaks API 2 and still accepts add-ons that declare `{1,1}`. An add-on +whose commands tool declares no descriptor works as before: the host treats it +as a provider of commands under the add-on's own title, with no note, and keeps +the isolation check for its document skills. The host never compares the add-on +ID with a fixed name for any of this, so a second add-on that runs commands gets +the same behavior with its own title. + ### Requested host operations are not grants Inside `capabilities`: diff --git a/swagger/docs.go b/swagger/docs.go index f36c0aac6..0dd0bd4b2 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -15140,6 +15140,43 @@ const docTemplate = `{ } } }, + "contract.ExecutionDescriptor": { + "type": "object", + "properties": { + "class": { + "description": "Class is ExecutionClassCommands.", + "type": "string" + }, + "note": { + "description": "Note is one plain sentence for the model when the provider reports\nisolated execution, such as the programs its environment has.", + "type": "string" + }, + "skills": { + "description": "Skills says which skills run on this provider.", + "allOf": [ + { + "$ref": "#/definitions/contract.ExecutionSkills" + } + ] + }, + "title": { + "description": "Title names the provider in the text the host shows the person and the\nmodel, for example \"Sandbox\". It falls back to the add-on's title.", + "type": "string" + } + } + }, + "contract.ExecutionSkills": { + "type": "object", + "properties": { + "providesRuntimeFor": { + "description": "ProvidesRuntimeFor lists skill kinds (SkillKindDocuments). The host\noffers the add-on's skills of those kinds only while the provider\nreports isolated execution.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, "contract.OutputDescription": { "type": "object", "properties": { @@ -15271,6 +15308,14 @@ const docTemplate = `{ "description": "Description is plain text for the model.", "type": "string" }, + "execution": { + "description": "Execution declares that this tool runs commands and what the host may\nsay about the provider. Only a tool that shadows bash may declare it, and\nthe manifest must then accept API 2 (see MinExecutionAPI).", + "allOf": [ + { + "$ref": "#/definitions/contract.ExecutionDescriptor" + } + ] + }, "inputSchema": { "description": "InputSchema is the JSON schema of the arguments object. It is an\nobject schema with explicit properties, additionalProperties false at\nthe top level and the keywords the host allows only.", "type": "object" diff --git a/swagger/swagger.json b/swagger/swagger.json index 0032884d9..0a2e5d84f 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -15137,6 +15137,43 @@ } } }, + "contract.ExecutionDescriptor": { + "type": "object", + "properties": { + "class": { + "description": "Class is ExecutionClassCommands.", + "type": "string" + }, + "note": { + "description": "Note is one plain sentence for the model when the provider reports\nisolated execution, such as the programs its environment has.", + "type": "string" + }, + "skills": { + "description": "Skills says which skills run on this provider.", + "allOf": [ + { + "$ref": "#/definitions/contract.ExecutionSkills" + } + ] + }, + "title": { + "description": "Title names the provider in the text the host shows the person and the\nmodel, for example \"Sandbox\". It falls back to the add-on's title.", + "type": "string" + } + } + }, + "contract.ExecutionSkills": { + "type": "object", + "properties": { + "providesRuntimeFor": { + "description": "ProvidesRuntimeFor lists skill kinds (SkillKindDocuments). The host\noffers the add-on's skills of those kinds only while the provider\nreports isolated execution.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, "contract.OutputDescription": { "type": "object", "properties": { @@ -15268,6 +15305,14 @@ "description": "Description is plain text for the model.", "type": "string" }, + "execution": { + "description": "Execution declares that this tool runs commands and what the host may\nsay about the provider. Only a tool that shadows bash may declare it, and\nthe manifest must then accept API 2 (see MinExecutionAPI).", + "allOf": [ + { + "$ref": "#/definitions/contract.ExecutionDescriptor" + } + ] + }, "inputSchema": { "description": "InputSchema is the JSON schema of the arguments object. It is an\nobject schema with explicit properties, additionalProperties false at\nthe top level and the keywords the host allows only.", "type": "object" diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 34f4e50d9..f447cc209 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -627,6 +627,37 @@ definitions: name: type: string type: object + contract.ExecutionDescriptor: + properties: + class: + description: Class is ExecutionClassCommands. + type: string + note: + description: |- + Note is one plain sentence for the model when the provider reports + isolated execution, such as the programs its environment has. + type: string + skills: + allOf: + - $ref: '#/definitions/contract.ExecutionSkills' + description: Skills says which skills run on this provider. + title: + description: |- + 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. + type: string + type: object + contract.ExecutionSkills: + properties: + providesRuntimeFor: + description: |- + ProvidesRuntimeFor lists skill kinds (SkillKindDocuments). The host + offers the add-on's skills of those kinds only while the provider + reports isolated execution. + items: + type: string + type: array + type: object contract.OutputDescription: properties: handle: @@ -724,6 +755,13 @@ definitions: description: description: Description is plain text for the model. type: string + execution: + allOf: + - $ref: '#/definitions/contract.ExecutionDescriptor' + description: |- + 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). inputSchema: description: |- InputSchema is the JSON schema of the arguments object. It is an From 9301fcf8b8eeea1278d471a751969c57d952874e Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 10:07:05 +0000 Subject: [PATCH 5/5] test(addons): cover the execution descriptor in the apps API shape The apps API spec checks that the generated docs match the emitted fields, so it needs a descriptor in its full fixture. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto --- core/http/endpoints/addons/apps_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/http/endpoints/addons/apps_test.go b/core/http/endpoints/addons/apps_test.go index 0e80d4ccb..bbef490d7 100644 --- a/core/http/endpoints/addons/apps_test.go +++ b/core/http/endpoints/addons/apps_test.go @@ -388,8 +388,8 @@ var _ = Describe("apps routes outcomes and documentation", Label("apps"), func() Expect(json.Unmarshal(b, &m)).To(Succeed()) return m } - full := apps.App{ID: "a", Name: "a", Title: "t", Titles: map[string]string{"en": "t"}, Description: "d", Descriptions: map[string]string{"en": "d"}, Icon: "i", Version: "1", InstalledAt: "2026-10-30T09:15:00Z", Generation: 1, Enabled: true, State: "running", Update: "update-pending", MainPage: "/m", HasUI: true, LaunchMode: "always", Tools: []string{"x"}, ToolCount: 1, Shadows: []string{"bash"}, ToolDefinitions: []contract.ToolDefinition{{Name: "x", Description: "d", Kind: "read", InputSchema: json.RawMessage(`{"type":"object"}`), UIOnly: true, Shadows: "bash"}}, Skills: []contract.SkillDefinition{{Name: "xlsx", Shadows: "xlsx"}}, SkillShadows: []string{"xlsx"}, UI: &apps.UIView{Entry: "/e", CSS: []string{"/c"}, SDK: contract.VersionRange{Min: 1, Max: 1}, Actions: []apps.ChatActionView{{ID: "a", Label: "A", Labels: map[string]string{"en": "A"}, Icon: "star", PackagedIcon: true, Placement: "composer", Tool: "t", Component: "C"}}, Slots: []apps.SlotView{{ID: "s", Slot: "files.preview", Component: "C", Extensions: []string{"docx"}}}, Renderers: []contract.Renderer{{Kind: "approval-card", Tool: "t", Component: "C"}}, Pages: []string{"p"}, Navigation: []string{"p"}, ChatActions: []string{"a"}, Controls: []string{"network-policy"}}, Admin: &apps.AdminView{CanStart: true, CanStop: true, ManagementURL: "/x"}} - for name, v := range map[string]any{"apps.App": full, "apps.AdminView": *full.Admin, "apps.UIView": *full.UI, "apps.ChatActionView": full.UI.Actions[0], "apps.SlotView": full.UI.Slots[0], "contract.Renderer": full.UI.Renderers[0], "contract.VersionRange": full.UI.SDK, "contract.ToolDefinition": full.ToolDefinitions[0], "contract.SkillDefinition": full.Skills[0], "apps.List": apps.List{Apps: []apps.App{full}}} { + full := apps.App{ID: "a", Name: "a", Title: "t", Titles: map[string]string{"en": "t"}, Description: "d", Descriptions: map[string]string{"en": "d"}, Icon: "i", Version: "1", InstalledAt: "2026-10-30T09:15:00Z", Generation: 1, Enabled: true, State: "running", Update: "update-pending", MainPage: "/m", HasUI: true, LaunchMode: "always", Tools: []string{"x"}, ToolCount: 1, Shadows: []string{"bash"}, ToolDefinitions: []contract.ToolDefinition{{Name: "x", Description: "d", Kind: "read", InputSchema: json.RawMessage(`{"type":"object"}`), UIOnly: true, Shadows: "bash", Execution: &contract.ExecutionDescriptor{Class: "commands", Title: "T", Note: "n", Skills: &contract.ExecutionSkills{ProvidesRuntimeFor: []string{"documents"}}}}}, Skills: []contract.SkillDefinition{{Name: "xlsx", Shadows: "xlsx"}}, SkillShadows: []string{"xlsx"}, UI: &apps.UIView{Entry: "/e", CSS: []string{"/c"}, SDK: contract.VersionRange{Min: 1, Max: 1}, Actions: []apps.ChatActionView{{ID: "a", Label: "A", Labels: map[string]string{"en": "A"}, Icon: "star", PackagedIcon: true, Placement: "composer", Tool: "t", Component: "C"}}, Slots: []apps.SlotView{{ID: "s", Slot: "files.preview", Component: "C", Extensions: []string{"docx"}}}, Renderers: []contract.Renderer{{Kind: "approval-card", Tool: "t", Component: "C"}}, Pages: []string{"p"}, Navigation: []string{"p"}, ChatActions: []string{"a"}, Controls: []string{"network-policy"}}, Admin: &apps.AdminView{CanStart: true, CanStop: true, ManagementURL: "/x"}} + for name, v := range map[string]any{"apps.App": full, "apps.AdminView": *full.Admin, "apps.UIView": *full.UI, "apps.ChatActionView": full.UI.Actions[0], "apps.SlotView": full.UI.Slots[0], "contract.Renderer": full.UI.Renderers[0], "contract.VersionRange": full.UI.SDK, "contract.ToolDefinition": full.ToolDefinitions[0], "contract.ExecutionDescriptor": *full.ToolDefinitions[0].Execution, "contract.ExecutionSkills": *full.ToolDefinitions[0].Execution.Skills, "contract.SkillDefinition": full.Skills[0], "apps.List": apps.List{Apps: []apps.App{full}}} { def, ok := doc.Definitions[name] Expect(ok).To(BeTrue(), name) shape := emitted(v)