Files
LocalAI/core/services/studio/capabilities_test.go
T
Ettore Di Giacinto 9fafea8fdf fix(chats): show the network switcher and say why execution status is unknown
The Network row of the Tools menu could never show the Off, Registries and
All switcher: the host stopped setting `applies` because isolation alone does
not prove network-policy enforcement, and nothing else set it.

Let the provider report enforcement in its typed execution status
(`networkPolicyEnforced`). The host sets `applies` only when that report is
true and the status is available and isolated. The Sandbox reports it for the
gVisor and KVM backends, whose guests reach the network only through the proxy
that asks the host. Providers that do not report it keep the "not enforced"
text.

When the host cannot read the status, give the unknown state a reason
(`unknown_reason`) in the API and the Tools menu: owner unreachable, not
reported, stale, invalid, not selected, no answer or reported unknown. Log the
class of each failure, rate limited. After a failure that a restart of the
add-on owner can cause, let a long turn bind and read again, at most once every
five seconds, instead of keeping the first failure for the whole turn.

The Sandbox now reports "unavailable" with a reason when it started without
settings, and explains a backend it does not know.

When the Sandbox document skills are hidden because the Sandbox is not known to
be isolated, say so in the skill list, in the turn note and in the
`load_skill` error, instead of "This chat has no skills now".

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-11-03 08:39:30 +00:00

281 lines
13 KiB
Go

package studio_test
import (
"context"
"github.com/mudler/LocalAI/core/services/studio/executionrequest"
model "github.com/mudler/LocalAI/pkg/addons/model/executionstatus"
"strings"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/services/studio"
"github.com/mudler/LocalAI/core/services/studio/files"
"github.com/mudler/LocalAI/core/services/studio/sandbox"
"github.com/mudler/LocalAI/core/services/studio/studiotest"
"github.com/mudler/LocalAI/core/services/studio/workspace"
)
// 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"
read := addonTool("sandbox", "read")
read.Action = true
return []studio.AddonTool{bash, read}
}
// isolatedAddons is a broker that says how its runner isolates commands.
type isolatedAddons struct {
*fakeAddons
mode string
}
func (i isolatedAddons) Isolation(context.Context, string, string) string { return i.mode }
// writeExecSkill writes a shared skill that needs a way to run commands.
func writeExecSkill(f *files.Service, name string) {
Expect(f.EnsureShared(context.Background())).To(Succeed())
body := "---\nname: " + name + "\ndescription: Work with " + name + " files.\nrequires: [exec]\n---\nRun python.\n"
_, err := f.Store().Put(context.Background(), workspace.Actor{Principal: files.AdminPrincipal}, "/skills/"+name+"/SKILL.md",
strings.NewReader(body), workspace.PutOptions{Overwrite: true})
Expect(err).NotTo(HaveOccurred())
}
// newCapsChat is a chat service with files and the given add-on broker.
func newCapsChat(broker studio.AddonTools, script func(workspace.Actor, string) []studiotest.ToolStep) (*studio.Service, *studiotest.ToolHarness, *files.Service) {
f := newFiles()
if script == nil {
script = func(workspace.Actor, string) []studiotest.ToolStep { return []studiotest.ToolStep{{Say: "ok"}} }
}
svc, h := newToolService(script, func(d *studio.Deps) {
d.Files = f
if broker != nil {
d.AddonTools = broker
}
})
return svc, h, f
}
func turnPrompt(svc *studio.Service, h *studiotest.ToolHarness, id string) string {
sent(svc, alice, id, "hello")
idle(svc, alice, id)
return h.TurnPrompt(id)
}
var _ = Describe("capabilities", func() {
It("tells a chat with no way to run commands what it can do instead", func() {
svc, h, f := newCapsChat(nil, nil)
writeExecSkill(f, "xlsx")
id := create(svc, alice)
prompt := turnPrompt(svc, h, id)
Expect(prompt).To(ContainSubstring("Commands: nothing in this chat runs code or commands."))
Expect(prompt).To(ContainSubstring("Never claim or imply that you ran anything"))
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("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")
Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsNone}))
})
It("keeps the block short and leaves out the skill line when no skill needs commands", func() {
svc, h, _ := newCapsChat(nil, nil)
id := create(svc, alice)
prompt := turnPrompt(svc, h, id)
var block string
for part := range strings.SplitSeq(prompt, "\n\n") {
if strings.HasPrefix(part, "Commands:") {
block = part
}
}
Expect(block).NotTo(BeEmpty(), "no Commands block in:\n%s", prompt)
Expect(block).NotTo(ContainSubstring("skill"))
// About 60 tokens: a word is more than one token on average, so count words.
Expect(len(strings.Fields(block))).To(BeNumerically("<=", 70), "the block is too long: %q", block)
})
It("names the tool that runs commands and lists the skills as usable", func() {
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: runAddon()}}
svc, h, f := newCapsChat(isolatedAddons{fakeAddons: fa, mode: "isolated"}, nil)
writeExecSkill(f, "xlsx")
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(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).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"}))
})
It("warns when commands run on the server with no isolation", func() {
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: runAddon()}}
svc, h, _ := newCapsChat(isolatedAddons{fakeAddons: fa, mode: "host"}, nil)
id := create(svc, alice)
Expect(turnPrompt(svc, h, id)).To(ContainSubstring("Commands: bash runs commands directly on the server, with no isolation."))
Expect(turnPrompt(svc, h, id)).NotTo(ContainSubstring(sandbox.GuestToolsNote), "the host has its own programs, not the guest's")
Expect(turnPrompt(svc, h, id)).NotTo(ContainSubstring("Skill folders are not visible"), "only an isolated chat has the hint")
})
It("says the Sandbox is off when the chat switched it off", func() {
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: runAddon()}}
svc, h, _ := newCapsChat(fa, nil)
id := create(svc, alice)
Expect(svc.SetAddonsOff(context.Background(), alice, id, []string{"sandbox"})).To(Succeed())
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"}))
})
It("says the Sandbox cannot run when no backend works", func() {
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: runAddon()}}
gate := gatedAddons{fakeAddons: fa, reasons: map[string]string{"sandbox": "no sandbox backend works on this host"}}
svc, h, _ := newCapsChat(gate, nil)
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"}))
})
It("does not call the add-on off for a user who cannot use it", func() {
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: runAddon()}}
svc, h, _ := newCapsChat(fa, nil)
a := create(svc, alice)
_ = turnPrompt(svc, h, a) // the service has now seen the Sandbox
b := create(svc, "bob")
Expect(svc.SetAddonsOff(context.Background(), "bob", b, []string{"sandbox"})).To(Succeed())
Expect(svc.CommandRunner(context.Background(), "bob", b)).To(Equal(studio.Commands{State: studio.CommandsNone}))
})
It("refuses a sub-agent task that asks to run something when nothing runs commands", func() {
svc, h, _ := newCapsChat(nil, func(_ workspace.Actor, text string) []studiotest.ToolStep {
if text == "go" {
return []studiotest.ToolStep{{Tool: "spawn_agent", Args: `{"task":"Actually execute the script /workspace/home/make_deck.py and report the output","wait":true}`}, {Say: "done"}}
}
return []studiotest.ToolStep{{Say: "ran it"}}
})
id := create(svc, alice)
sent(svc, alice, id, "go")
idle(svc, alice, id)
res := h.ResultsFor("spawn_agent")
Expect(res).To(HaveLen(1))
Expect(res[0].Err).To(MatchError(studio.ErrSubAgentCannotRun))
Expect(res[0].Err.Error()).To(ContainSubstring("the sub-agent has no way to run commands either"))
Expect(childrenOf(svc, alice, id)).To(BeEmpty(), "no sub-agent may start for a task it cannot do")
})
It("starts a sub-agent that asks to run something when the chat can run commands", func() {
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: runAddon()}}
svc, h, _ := newCapsChat(fa, func(_ workspace.Actor, text string) []studiotest.ToolStep {
if text == "go" {
return []studiotest.ToolStep{{Tool: "spawn_agent", Args: `{"task":"Run python3 make_deck.py","wait":true}`}, {Say: "done"}}
}
return []studiotest.ToolStep{{Say: "built"}}
})
id := create(svc, alice)
sent(svc, alice, id, "go")
idle(svc, alice, id)
res := h.ResultsFor("spawn_agent")
Expect(res).To(HaveLen(1))
Expect(res[0].Err).NotTo(HaveOccurred())
Expect(res[0].Out).To(ContainSubstring("built"))
kids := childrenOf(svc, alice, id)
Expect(kids).To(HaveLen(1))
// A sub-agent that can run commands needs no commands block; it is told
// where its parent keeps working files.
Expect(h.TurnPrompt(kids[0].ID)).NotTo(ContainSubstring("Commands:"), "a sub-agent that can run commands needs no block")
Expect(h.TurnPrompt(kids[0].ID)).To(HavePrefix("Working files:"))
})
It("tells a sub-agent without a way to run commands not to pretend", func() {
svc, h, _ := newCapsChat(nil, func(_ workspace.Actor, text string) []studiotest.ToolStep {
if text == "go" {
return []studiotest.ToolStep{{Tool: "spawn_agent", Args: `{"task":"summarize the notes","wait":true}`}, {Say: "done"}}
}
return []studiotest.ToolStep{{Say: "summary"}}
})
id := create(svc, alice)
sent(svc, alice, id, "go")
idle(svc, alice, id)
kids := childrenOf(svc, alice, id)
Expect(kids).To(HaveLen(1))
Expect(h.TurnPrompt(kids[0].ID)).To(ContainSubstring("Commands: nothing here runs code or commands."))
})
It("refuses the same sub-agent task again while no file of the chat changed", func() {
svc, h, _ := newCapsChat(nil, func(_ workspace.Actor, text string) []studiotest.ToolStep {
switch text {
case "ask":
return []studiotest.ToolStep{{Tool: "spawn_agent", Args: `{"task":"Check the numbers in q1.csv","wait":true}`}, {Say: "asked"}}
case "ask again":
return []studiotest.ToolStep{{Tool: "spawn_agent", Args: `{"task":"check the numbers in q1.csv","wait":true}`}, {Say: "asked"}}
case "write":
return []studiotest.ToolStep{{Tool: "write_file", Args: `{"path":"/q1.csv","content":"a,b\n1,2\n"}`}, {Say: "written"}}
}
return []studiotest.ToolStep{{Say: "the numbers add up"}}
})
id := create(svc, alice)
for _, msg := range []string{"ask", "ask again", "write", "ask again"} {
sent(svc, alice, id, msg)
idle(svc, alice, id)
}
res := h.ResultsFor("spawn_agent")
Expect(res).To(HaveLen(3))
Expect(res[0].Err).NotTo(HaveOccurred())
Expect(res[1].Err).To(MatchError(studio.ErrSubAgentRepeat), "the same task with no new file must be refused")
Expect(res[2].Err).NotTo(HaveOccurred(), "a file changed since the first run, so the task may run again")
Expect(childrenOf(svc, alice, id)).To(HaveLen(2))
})
})
// presentationAddons supplies an admitted request just as the product broker does.
type presentationAddons struct {
*fakeAddons
bound bool
}
func (p presentationAddons) NewExecutionObservation(ctx context.Context, user, conv string) (*executionrequest.Request, string, func() studio.ExecutionDetail) {
if !p.bound {
return nil, "", nil
}
b := model.Binding{Actor: user, Addon: "sandbox", Generation: "27", Incarnation: "runtime-two", ExecutionTool: "bash", StatusTool: "status"}
r := executionrequest.New(ctx, b, "q", model.Eligible, executionrequest.IO{
Validate: func(context.Context, model.Binding) error { return nil },
Read: func(context.Context, model.Binding, model.ObservationID, time.Time) (model.Payload, error) {
return model.ValidPayload(model.Value{Readiness: model.Available, Isolation: model.Host}), nil
},
}, nil)
Expect(r.Binding()).To(Equal(b))
return r, b.Addon, func() studio.ExecutionDetail { return studio.ExecutionDetail{Explanation: "plain explanation"} }
}
var _ = Describe("execution presentation DTO", func() {
It("returns admitted binding identity through CommandRunner and no identity without admission", func() {
for _, bound := range []bool{true, false} {
broker := presentationAddons{fakeAddons: &fakeAddons{tools: map[string][]studio.AddonTool{alice: runAddon()}}, bound: bound}
svc, _, _ := newCapsChat(broker, nil)
id := create(svc, alice)
c, err := svc.CommandRunner(context.Background(), alice, id)
Expect(err).NotTo(HaveOccurred())
Expect(c.ExecutionStatus).NotTo(BeNil())
if bound {
Expect(c.ExecutionStatus.Generation).To(Equal("27"))
Expect(c.ExecutionStatus.Incarnation).To(Equal("runtime-two"))
Expect(c.ExecutionStatus.Readiness).To(Equal("available"))
} else {
Expect(c.ExecutionStatus.Generation).To(BeEmpty())
Expect(c.ExecutionStatus.Readiness).To(Equal("unknown"))
}
}
})
})