From a7771d06cfac09c5ff34bb120499300938655ae0 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 09:53:53 +0000 Subject: [PATCH] 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" ] }, {