From 9fafea8fdf5290569efbc3c8fb1ffb7efd117eed Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 08:33:48 +0000 Subject: [PATCH] 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 --- .../internal/tools/executionstatus_test.go | 36 +++- addons/sandbox/internal/tools/tools.go | 11 +- core/application/execution_binding_test.go | 31 ++- core/application/studio_execution.go | 123 +++++++++++- core/http/endpoints/studio/addons.go | 2 +- core/http/react-ui/e2e/chats-commands.spec.js | 17 ++ core/http/react-ui/e2e/chats-egress.spec.js | 13 +- .../react-ui/public/locales/en/chats.json | 9 + .../src/components/chat/ChatComposer.jsx | 3 +- .../react-ui/src/hooks/useCommandsNotice.js | 4 +- core/http/react-ui/src/utils/chatCommands.js | 20 +- .../react-ui/tests/unit/chatcommands.test.mjs | 19 +- core/schema/chats.go | 14 +- core/services/studio/addonskills.go | 41 ++++ core/services/studio/capabilities_test.go | 4 +- .../documenteligibility_internal_test.go | 65 +++++++ core/services/studio/egress_test.go | 69 +++++++ core/services/studio/egressapi.go | 19 +- core/services/studio/executionobservation.go | 181 +++++++++++++++--- .../studio/executionobservation_test.go | 145 +++++++++++++- core/services/studio/loadskill.go | 37 +++- core/services/studio/skillsvc.go | 4 +- docs/content/features/addons.md | 17 +- docs/content/features/chats.md | 2 +- pkg/addons/contract/executionstatus.go | 10 +- pkg/addons/contract/executionstatus_test.go | 18 ++ 26 files changed, 833 insertions(+), 81 deletions(-) diff --git a/addons/sandbox/internal/tools/executionstatus_test.go b/addons/sandbox/internal/tools/executionstatus_test.go index 23caf6ffb..1a9764a7b 100644 --- a/addons/sandbox/internal/tools/executionstatus_test.go +++ b/addons/sandbox/internal/tools/executionstatus_test.go @@ -20,8 +20,11 @@ type typedSettings struct { func (s typedSettings) Status() backends.Status { return s.status } var _ = Describe("typed execution status", func() { - for _, tc := range []struct{ active, readiness, isolation string }{ - {"gvisor", "available", "isolated"}, {"kvm", "available", "isolated"}, {"host", "available", "host"}, {"", "unavailable", "unknown"}, {"future", "unknown", "unknown"}, + for _, tc := range []struct { + active, readiness, isolation string + enforced bool + }{ + {"gvisor", "available", "isolated", true}, {"kvm", "available", "isolated", true}, {"host", "available", "host", false}, {"", "unavailable", "unknown", false}, {"future", "unknown", "unknown", false}, } { It(fmt.Sprintf("reports %s as %s with %s isolation", tc.active, tc.readiness, tc.isolation), func() { r := New(nil, typedSettings{status: backends.Status{Active: tc.active}}, nil) @@ -34,6 +37,35 @@ var _ = Describe("typed execution status", func() { Expect(err).NotTo(HaveOccurred()) Expect(status.Readiness).To(Equal(tc.readiness)) Expect(status.Isolation).To(Equal(tc.isolation)) + Expect(status.NetworkPolicyEnforced).To(Equal(tc.enforced)) }) } }) + +var _ = Describe("typed execution status without settings", func() { + It("reports unavailable with a reason, not unknown, when the add-on started without settings", func() { + r := New(nil, nil, nil) + var env contract.Envelope + env.Request.OperationID = "typed-read" + env.Request.Arguments = json.RawMessage(`{"tool":"execution-status"}`) + result, err := r.Read(context.Background(), env) + Expect(err).NotTo(HaveOccurred()) + status, err := contract.DecodeExecutionStatus(result.Payload) + Expect(err).NotTo(HaveOccurred()) + Expect(status.Readiness).To(Equal("unavailable")) + Expect(status.Explanation).NotTo(BeEmpty()) + Expect(status.NetworkPolicyEnforced).To(BeFalse()) + }) + It("explains an active backend it does not know", func() { + r := New(nil, typedSettings{status: backends.Status{Active: "future"}}, nil) + var env contract.Envelope + env.Request.OperationID = "typed-read" + env.Request.Arguments = json.RawMessage(`{"tool":"execution-status"}`) + result, err := r.Read(context.Background(), env) + Expect(err).NotTo(HaveOccurred()) + status, err := contract.DecodeExecutionStatus(result.Payload) + Expect(err).NotTo(HaveOccurred()) + Expect(status.Readiness).To(Equal("unknown")) + Expect(status.Explanation).NotTo(BeEmpty()) + }) +}) diff --git a/addons/sandbox/internal/tools/tools.go b/addons/sandbox/internal/tools/tools.go index 8fe7e5fe8..c763eab1b 100644 --- a/addons/sandbox/internal/tools/tools.go +++ b/addons/sandbox/internal/tools/tools.go @@ -184,15 +184,24 @@ func (r *runtime) Read(ctx context.Context, env contract.Envelope) (contract.Res // The typed observation is callback-free, including administrator lookups. if a.Tool == ToolExecutionStatus { status := contract.ExecutionStatus{Readiness: "unknown", Isolation: "unknown"} - if r.settings != nil { + // The add-on starts without settings only when it could not read them + // or start its manager: it can run nothing, and says so. + if r.settings == nil { + status.Readiness, status.Explanation = "unavailable", "The Sandbox could not start." + } else { switch r.settings.Status().Active { case "gvisor", "kvm": status.Readiness, status.Isolation = "available", "isolated" + // The guest has no network device: its connections reach the + // network only through the proxy that asks the host. + status.NetworkPolicyEnforced = true case "host": status.Readiness, status.Isolation = "available", "host" case "": status.Readiness = "unavailable" status.Explanation = "No execution backend is available." + default: + status.Explanation = "The active execution backend is not recognized." } } return contract.Result{Payload: protocol.Payload(status)}, nil diff --git a/core/application/execution_binding_test.go b/core/application/execution_binding_test.go index 96b84bad6..251f5f615 100644 --- a/core/application/execution_binding_test.go +++ b/core/application/execution_binding_test.go @@ -7,6 +7,7 @@ import ( "context" "crypto/sha256" "encoding/json" + "errors" "fmt" "net" "net/http" @@ -331,11 +332,16 @@ var _ = Describe("execution binding owner resolver", func() { value := request.Observe(ctx) Expect(string(value.Readiness)).To(Equal("available")) Expect(string(value.Isolation)).To(Equal("isolated")) - Expect(explanation()).To(BeEmpty()) + Expect(explanation().Explanation).To(BeEmpty()) Expect(request.Observe(ctx)).To(Equal(value)) Expect(a.studioStore.SetAddonsOff(ctx, studio.AnonymousPrincipal, claim.ConversationID, []string{"renamed-runner", "alternate-worker"})).To(Succeed()) Expect(string(request.Observe(ctx).Isolation)).To(Equal("unknown")) + Expect(explanation().Failure).To(Equal(studio.UnknownStale)) Expect(broker.Isolation(ctx, studio.AnonymousPrincipal, "renamed-runner")).To(BeEmpty()) + none, selectedNone, noneDetail := broker.NewExecutionObservation(ctx, studio.AnonymousPrincipal, claim.ConversationID) + Expect(none).To(BeNil()) + Expect(selectedNone).To(BeEmpty()) + Expect(noneDetail().Failure).To(Equal(studio.UnknownNotReported)) Expect(a.studioStore.SetAddonsOff(ctx, studio.AnonymousPrincipal, claim.ConversationID, nil)).To(Succeed()) fresh, selected, _ := broker.NewExecutionObservation(ctx, studio.AnonymousPrincipal, claim.ConversationID) Expect(fresh).NotTo(BeNil()) @@ -439,3 +445,26 @@ var _ = Describe("workspace operation declarations", func() { Entry("similar prefix", []string{"workspaces.read", "workspace-other.read", "Workspace.read", "other.workspace.read"}, false), ) }) + +var _ = Describe("why an execution status could not be read", func() { + DescribeTable("names the reason", func(stage string, err error, want studio.UnknownReason) { + Expect(executionFailureReason(stage, err)).To(Equal(want)) + }, + Entry("owner down", "read", gateway.ErrUnavailable, studio.UnknownOwnerUnreachable), + Entry("owner not sent to", "read", gateway.ErrNotSent, studio.UnknownOwnerUnreachable), + Entry("owner down while binding", "binding", gateway.ErrUnavailable, studio.UnknownOwnerUnreachable), + Entry("carrier error", "read", errors.New("carrier"), studio.UnknownOwnerUnreachable), + Entry("no status tool bound", "binding", gateway.ErrForbidden, studio.UnknownNotReported), + Entry("binding mismatch on read", "read", gateway.ErrForbidden, studio.UnknownStale), + Entry("binding changed on validate", "validate", gateway.ErrForbidden, studio.UnknownStale), + Entry("payload refused", "decode", errors.New("invalid execution readiness"), studio.UnknownInvalid), + Entry("no answer in time", "read", context.DeadlineExceeded, studio.UnknownNoAnswer), + ) + It("logs each reason at most once a minute", func() { + t := time.Now() + Expect(failureLogDue("test-reason", t)).To(BeTrue()) + Expect(failureLogDue("test-reason", t.Add(failureLogEvery/2))).To(BeFalse()) + Expect(failureLogDue("other-reason", t.Add(failureLogEvery/2))).To(BeTrue()) + Expect(failureLogDue("test-reason", t.Add(failureLogEvery))).To(BeTrue()) + }) +}) diff --git a/core/application/studio_execution.go b/core/application/studio_execution.go index 3b5243f98..6a18221cd 100644 --- a/core/application/studio_execution.go +++ b/core/application/studio_execution.go @@ -6,6 +6,7 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" + "errors" "strconv" "sync" "time" @@ -15,67 +16,169 @@ import ( "github.com/mudler/LocalAI/core/services/studio/executionrequest" "github.com/mudler/LocalAI/pkg/addons/contract" model "github.com/mudler/LocalAI/pkg/addons/model/executionstatus" + "github.com/mudler/xlog" ) -func (l lateStudioAddons) NewExecutionObservation(ctx context.Context, user, conversation string) (*executionrequest.Request, string, func() string) { +func (l lateStudioAddons) NewExecutionObservation(ctx context.Context, user, conversation string) (*executionrequest.Request, string, func() studio.ExecutionDetail) { if p, ok := l.a.StudioAddonTools().(studio.ExecutionObserver); ok { return p.NewExecutionObservation(ctx, user, conversation) } return nil, "", nil } -func (s *studioAddons) NewExecutionObservation(ctx context.Context, user, conversation string) (*executionrequest.Request, string, func() string) { +func (s *studioAddons) NewExecutionObservation(ctx context.Context, user, conversation string) (*executionrequest.Request, string, func() studio.ExecutionDetail) { + var mu sync.Mutex + var detail studio.ExecutionDetail + replied := false + var addonID string + // fail records why no status could be read. The first failure wins: later + // ones are consequences of it. + fail := func(stage string, err error) { + reason := executionFailureReason(stage, err) + mu.Lock() + first := detail.Failure == "" + if first { + detail.Failure = reason + } + mu.Unlock() + if first && !errors.Is(err, context.Canceled) { + logExecutionFailure(stage, reason, err, addonID, conversation) + } + } + current := func() studio.ExecutionDetail { + mu.Lock() + defer mu.Unlock() + d := detail + if d.Failure == "" && !replied { + d.Failure = studio.UnknownNoAnswer + } + return d + } actor, err := s.actor(user) if err != nil { - return nil, "", nil + fail("binding", err) + return nil, "", current } b, err := s.adapter.BindingForActor(ctx, actor, gateway.ExecutionBindingClaim{ConversationID: conversation}) if err != nil { - return nil, "", nil + fail("binding", err) + return nil, "", current } var id [24]byte if _, err = rand.Read(id[:]); err != nil { - return nil, "", nil + fail("binding", err) + return nil, "", current } + addonID = b.AddonID q := model.ObservationID(hex.EncodeToString(id[:])) // Keep actor and conversation identity explicit. IO retains the original DTO. mb := model.Binding{Actor: actor.UserID, Credential: actor.CredentialID, Conversation: b.ConversationID, Mode: actor.Mode, Addon: b.AddonID, Generation: strconv.FormatUint(b.Generation, 10), Incarnation: b.Incarnation, ExecutionTool: b.ExecutionTool, StatusTool: b.StatusTool} - var mu sync.Mutex - explanation := "" io := executionrequest.IO{ Validate: func(ctx context.Context, current model.Binding) error { if current != mb { + fail("validate", gateway.ErrForbidden) return gateway.ErrForbidden } - return s.adapter.ValidateExecutionBinding(ctx, actor, b) + if err := s.adapter.ValidateExecutionBinding(ctx, actor, b); err != nil { + fail("validate", err) + return err + } + return nil }, Read: func(ctx context.Context, current model.Binding, observation model.ObservationID, deadline time.Time) (model.Payload, error) { if current != mb || observation != q { + fail("read", gateway.ErrForbidden) return model.InvalidPayload(), gateway.ErrForbidden } ctx, cancel := context.WithDeadline(ctx, deadline) defer cancel() reply, err := s.adapter.ExecutionStatusRead(ctx, actor, gateway.ExecutionStatusRequest{Version: 1, Binding: b, ObservationID: string(q)}) if err != nil { + fail("read", err) return model.InvalidPayload(), err } if reply.Version != 1 || reply.Binding != b || reply.ObservationID != string(q) { + fail("read", gateway.ErrForbidden) return model.InvalidPayload(), gateway.ErrForbidden } raw, err := json.Marshal(reply.Status) if err != nil { + fail("decode", err) return model.InvalidPayload(), err } status, err := contract.DecodeExecutionStatus(raw) if err != nil { + fail("decode", err) return model.InvalidPayload(), err } mu.Lock() - explanation = status.Explanation + detail.Explanation, detail.NetworkPolicyEnforced = status.Explanation, status.NetworkPolicyEnforced + replied = true mu.Unlock() return model.ValidPayload(model.Value{Readiness: model.Readiness(status.Readiness), Isolation: model.Isolation(status.Isolation)}), nil }, } request := executionrequest.New(ctx, mb, q, model.Eligible, io, nil) - return request, b.AddonID, func() string { mu.Lock(); defer mu.Unlock(); return explanation } + return request, b.AddonID, current +} + +// executionFailureReason maps a failure to the reason the person is told. The +// gateway answers forbidden for every refusal, so the stage tells them apart: +// while binding, the chat has no add-on that reports a status; later, what was +// bound no longer matches. +func executionFailureReason(stage string, err error) studio.UnknownReason { + switch { + case errors.Is(err, context.DeadlineExceeded): + return studio.UnknownNoAnswer + case errors.Is(err, gateway.ErrUnavailable): + return studio.UnknownOwnerUnreachable + case errors.Is(err, gateway.ErrForbidden): + if stage == "binding" { + return studio.UnknownNotReported + } + return studio.UnknownStale + case stage == "decode": + return studio.UnknownInvalid + } + return studio.UnknownOwnerUnreachable +} + +// failureLogEvery bounds the log line per reason: a chat page asks for the +// status often and a broken add-on would fill the log. +const failureLogEvery = time.Minute + +var failureLog = struct { + sync.Mutex + last map[studio.UnknownReason]time.Time +}{last: map[studio.UnknownReason]time.Time{}} + +// failureLogDue says whether a failure of this reason may be logged at now, +// and counts it if so. +func failureLogDue(reason studio.UnknownReason, now time.Time) bool { + failureLog.Lock() + defer failureLog.Unlock() + if last, ok := failureLog.last[reason]; ok && now.Sub(last) < failureLogEvery { + return false + } + failureLog.last[reason] = now + return true +} + +// logExecutionFailure logs the class of a failure, never its text, which may +// carry add-on or user data. +func logExecutionFailure(stage string, reason studio.UnknownReason, err error, addon, conversation string) { + if !failureLogDue(reason, time.Now()) { + return + } + class := "other" + switch { + case errors.Is(err, context.DeadlineExceeded): + class = "deadline" + case errors.Is(err, gateway.ErrUnavailable): + class = "unavailable" + case errors.Is(err, gateway.ErrForbidden): + class = "forbidden" + } + xlog.Warn("Chats: the execution status could not be read", "stage", stage, "reason", string(reason), "class", class, + "addon", addon, "chat", conversation[:min(len(conversation), 8)]) } diff --git a/core/http/endpoints/studio/addons.go b/core/http/endpoints/studio/addons.go index 362839ac3..e1c4e19b3 100644 --- a/core/http/endpoints/studio/addons.go +++ b/core/http/endpoints/studio/addons.go @@ -25,7 +25,7 @@ func writeAddonList(c echo.Context, list []studiosvc.AddonInfo, cmds studiosvc.C CommandsIsolation: cmds.Isolation, CommandsProvider: cmds.Provider, CommandsConsent: cmds.Consent, CommandsFallback: cmds.Fallback, CommandsFallbackReason: cmds.FallbackReason} if v := cmds.ExecutionStatus; v != nil { - out.ExecutionStatus = &schema.ChatsExecutionStatus{Readiness: v.Readiness, Isolation: v.Isolation, Explanation: v.Explanation, Generation: v.Generation, Incarnation: v.Incarnation} + out.ExecutionStatus = &schema.ChatsExecutionStatus{Readiness: v.Readiness, Isolation: v.Isolation, Explanation: v.Explanation, UnknownReason: v.UnknownReason, Generation: v.Generation, Incarnation: v.Incarnation} } for _, a := range list { out.Addons = append(out.Addons, schema.ChatsAddon{ID: a.ID, Tools: a.Tools, Off: a.Off}) diff --git a/core/http/react-ui/e2e/chats-commands.spec.js b/core/http/react-ui/e2e/chats-commands.spec.js index 5bc68c4b1..47d8e15ee 100644 --- a/core/http/react-ui/e2e/chats-commands.spec.js +++ b/core/http/react-ui/e2e/chats-commands.spec.js @@ -123,6 +123,23 @@ test.describe('Chat that cannot run commands', () => { await expect(notice.getByTestId('chat-commands-notice-link')).toHaveCount(0) }) + test('an unknown status says why the host could not read it', async ({ page }) => { + await stubChat(page, () => ({ addons: [{ id: 'sandbox', tools: 1, off: false }], commands: 'available', commands_provider: 'sandbox', execution_status: { readiness: 'unknown', isolation: 'unknown', unknown_reason: 'owner_unreachable' } })) + await page.goto('/app/chats/c1') + await page.getByTestId('chat-tools-button').click() + const notice = page.getByTestId('chat-commands-notice') + await expect(notice).toContainText('Execution readiness or isolation is unknown: the add-on owner could not be reached.') + await shot(page, 'commands-unknown-reason') + }) + + test('an unknown status without a reason keeps the generic text', async ({ page }) => { + await stubChat(page, () => ({ addons: [{ id: 'sandbox', tools: 1, off: false }], commands: 'available', commands_provider: 'sandbox', execution_status: { readiness: 'unknown', isolation: 'unknown' } })) + await page.goto('/app/chats/c1') + await page.getByTestId('chat-tools-button').click() + await expect(page.getByTestId('chat-commands-notice')).toContainText('Execution readiness or isolation is unknown.') + await expect(page.getByTestId('chat-commands-notice')).not.toContainText('add-on') + }) + test('a chat that runs commands shows neither the chip nor the notice', async ({ page }) => { await stubChat(page, () => ({ addons: [{ id: 'sandbox', tools: 7, off: false }], commands: 'available', commands_provider: 'sandbox', commands_isolation: 'isolated' })) await page.goto('/app/chats/c1') diff --git a/core/http/react-ui/e2e/chats-egress.spec.js b/core/http/react-ui/e2e/chats-egress.spec.js index 394dea97c..79ec1c15f 100644 --- a/core/http/react-ui/e2e/chats-egress.spec.js +++ b/core/http/react-ui/e2e/chats-egress.spec.js @@ -30,7 +30,7 @@ const DETAIL = { id: 'c1', title: 'Packages', model: 'qwen3-8b', kind: 'chat', created: '2026-10-30T10:00:00Z', updated: '2026-10-30T10:00:00Z', running: false, last_turn: 'done', pending: [], } -const sandbox = { addons: [{ id: 'sandbox', tools: 2, off: false }], commands: 'available', commands_provider: 'sandbox', commands_isolation: 'isolated' } +const sandbox = { addons: [{ id: 'sandbox', tools: 2, off: false }], commands: 'available', commands_provider: 'sandbox', commands_isolation: 'isolated', execution_status: { readiness: 'available', isolation: 'isolated' } } const view = (over = {}) => ({ preset: 'off', ceiling: 'registries', effective: 'off', applies: true, applies_reason: '', addon: 'sandbox', registries: [{ id: 'pypi', title: 'Python (PyPI)', hosts: ['pypi.org', 'files.pythonhosted.org'] }], allowed_hosts: [], rules: [], cards: [], ...over, @@ -154,6 +154,17 @@ test.describe('Network of the Sandbox commands', () => { await expect(page.getByTestId('chat-network')).toHaveCount(0) }) + test('a runner that does not report enforcement shows the not-enforced line and no switcher', async ({ page }) => { + // What the server sends when the isolated provider's status has no + // network-policy enforcement report. + await stubChat(page, { egress: view({ applies: false, applies_reason: 'none' }) }) + await page.goto('/app/chats/c1') + await page.getByTestId('chat-tools-button').click() + await expect(page.getByTestId('chat-network-unenforced')).toContainText('Network-policy enforcement is not verified') + await expect(page.getByTestId('chat-network')).toHaveCount(0) + await shot(page, 'network-not-enforced') + }) + test('with no add-on that draws the network controls, the Tools menu has no Network row', async ({ page }) => { await stubChat(page, { egress: view({ addon: undefined }) }) await page.goto('/app/chats/c1') diff --git a/core/http/react-ui/public/locales/en/chats.json b/core/http/react-ui/public/locales/en/chats.json index 857ecf743..a1cf1570e 100644 --- a/core/http/react-ui/public/locales/en/chats.json +++ b/core/http/react-ui/public/locales/en/chats.json @@ -412,6 +412,15 @@ "hostChipTitle": "Commands in this chat run on this machine, not isolated. Select for details.", "providerHost": "Commands run on the server without isolation.", "unknown": "Execution readiness or isolation is unknown.", + "unknownBy": { + "owner_unreachable": "Execution readiness or isolation is unknown: the add-on owner could not be reached.", + "not_reported": "Execution readiness or isolation is unknown: the add-on has not reported its execution status.", + "stale": "Execution readiness or isolation is unknown: the status did not match the add-on's current version.", + "invalid_status": "Execution readiness or isolation is unknown: the add-on sent a status the host could not accept.", + "not_selected": "Execution readiness or isolation is unknown: the add-on is not the one that runs this chat's commands.", + "no_answer": "Execution readiness or isolation is unknown: the add-on did not answer in time.", + "reported_unknown": "Execution readiness or isolation is unknown: the add-on reported that it cannot tell." + }, "providerUnavailable": "The selected execution provider is unavailable.", "disabled": "Running commands is switched off for this chat." }, diff --git a/core/http/react-ui/src/components/chat/ChatComposer.jsx b/core/http/react-ui/src/components/chat/ChatComposer.jsx index d7aa51f87..f7d2a9cc9 100644 --- a/core/http/react-ui/src/components/chat/ChatComposer.jsx +++ b/core/http/react-ui/src/components/chat/ChatComposer.jsx @@ -6,6 +6,7 @@ import { CAP_CHAT } from '../../utils/capabilities' import { seesImages } from '../../utils/chatVision' import { connectorRef } from '../../utils/chatMCP' import { networkMode } from '../../utils/chatEgress' +import { unknownTextKey } from '../../utils/chatCommands' import { gbLabel, gbNumber } from '../../utils/modelLedger' import { useModelFit } from '../../hooks/useModelFit' import { useCommandsNotice } from '../../hooks/useCommandsNotice' @@ -122,7 +123,7 @@ export default function ChatComposer({ if (!by || selected.provider !== by) return { chip, note: tc('tools.commands.unknown') } if (['host', 'host-off', 'host-unavailable', 'provider-host'].includes(selected.state)) return { chip, note: tc('tools.commands.providerHost'), warn: true } if (selected.state === 'available') return { chip, note: tc('tools.shell.isolated') } - return { chip, note: tc('tools.commands.unknown') } + return { chip, note: tc(`tools.commands.${unknownTextKey(selected.code)}`) } } // What a row of the model list says: whether it is loaded, what it can do diff --git a/core/http/react-ui/src/hooks/useCommandsNotice.js b/core/http/react-ui/src/hooks/useCommandsNotice.js index 1c775bc1c..fba2f5075 100644 --- a/core/http/react-ui/src/hooks/useCommandsNotice.js +++ b/core/http/react-ui/src/hooks/useCommandsNotice.js @@ -4,8 +4,8 @@ import { commandsNotice } from '../utils/chatCommands' // Dismissal is deliberately mount-local: logout/remount never retains it. // Exact binding, observation and actor/chat context must match, not just state. export function useCommandsNotice(commands, isAdmin, context) { - const info = commandsNotice(commands.state, { isAdmin, reason: commands.reason }) - const identity = JSON.stringify([context, commands.identity, commands.state, commands.reason]) + const info = commandsNotice(commands.state, { isAdmin, reason: commands.reason, code: commands.code }) + const identity = JSON.stringify([context, commands.identity, commands.state, commands.reason, commands.code]) const [hiddenIdentity, setHiddenIdentity] = useState('') useEffect(() => { setHiddenIdentity('') }, [identity]) const hide = useCallback(() => { diff --git a/core/http/react-ui/src/utils/chatCommands.js b/core/http/react-ui/src/utils/chatCommands.js index 602e0ba12..e4dfc7edf 100644 --- a/core/http/react-ui/src/utils/chatCommands.js +++ b/core/http/react-ui/src/utils/chatCommands.js @@ -1,6 +1,14 @@ // Command availability and isolation come from the selected provider DTO, not // an add-on's private discovery response or its name. +// Why the host has no execution status (execution_status.unknown_reason). +// Each code has its own sentence under chats:tools.commands.unknownBy. +const UNKNOWN_REASONS = ['owner_unreachable', 'not_reported', 'stale', 'invalid_status', 'not_selected', 'no_answer', 'reported_unknown'] + +// The text key of the notice of an unknown state, with the reason when known. +export const unknownTextKey = (code) => (UNKNOWN_REASONS.includes(code) ? `unknownBy.${code}` : 'unknown') +const reasonCode = (observation) => (UNKNOWN_REASONS.includes(observation?.unknown_reason) ? observation.unknown_reason : '') + // commandsState reads the commands fields of an add-on list as one state and // its reason. export function commandsState(res) { @@ -15,11 +23,11 @@ export function commandsState(res) { const observation = res?.execution_status if (state === 'available') { const isolation = observation?.isolation || res?.commands_isolation - if (observation && observation.readiness !== 'available') return { state: 'unknown', reason: plainExplanation(observation.explanation) } + if (observation && observation.readiness !== 'available') return { state: 'unknown', reason: plainExplanation(observation.explanation), code: reasonCode(observation) } if (isolation === 'host') return { state: 'provider-host', reason: plainExplanation(observation?.explanation) } - if (isolation !== 'isolated') return { state: 'unknown', reason: plainExplanation(observation?.explanation) } + if (isolation !== 'isolated') return { state: 'unknown', reason: plainExplanation(observation?.explanation), code: reasonCode(observation) } } - if (state === 'none' && observation) return { state: 'unknown', reason: plainExplanation(observation.explanation) } + if (state === 'none' && observation) return { state: 'unknown', reason: plainExplanation(observation.explanation), code: reasonCode(observation) } if (state === 'unavailable' && (observation || res?.commands_provider)) return { state: 'provider-unavailable', reason: plainExplanation(observation?.explanation || reason) } return { state, reason } } @@ -27,14 +35,14 @@ export function commandsState(res) { // The notice for a state, or null when commands run (or the state is unknown). // keys are under chats:tools.commands. link is for administrators only; a // person who is not one is told to ask one, unless the fix is theirs. -export function commandsNotice(commands, { isAdmin = false, reason = '' } = {}) { +export function commandsNotice(commands, { isAdmin = false, reason = '', code = '' } = {}) { switch (commands) { case 'provider-host': case 'unknown': case 'loading': case 'provider-unavailable': case 'switched_off': - return { state: commands, titleKey: commands === 'provider-host' ? 'hostTitle' : 'title', textKey: commands === 'switched_off' ? 'disabled' : commands === 'provider-host' ? 'providerHost' : commands === 'provider-unavailable' ? 'providerUnavailable' : 'unknown', reason: plainExplanation(reason), tone: 'warn', link: null, askKey: null } + return { state: commands, titleKey: commands === 'provider-host' ? 'hostTitle' : 'title', textKey: commands === 'switched_off' ? 'disabled' : commands === 'provider-host' ? 'providerHost' : commands === 'provider-unavailable' ? 'providerUnavailable' : unknownTextKey(code), reason: plainExplanation(reason), tone: 'warn', link: null, askKey: null } case 'off': return { state: 'off', textKey: 'disabled', link: null, askKey: null, reason: '' } @@ -121,5 +129,5 @@ export function plainExplanation(value) { export function noticeIdentity(res, chat, actor) { const v = res?.execution_status || {} - return JSON.stringify([actor, chat, res?.commands_provider, v.generation, v.incarnation, res?.commands, v.readiness, v.isolation || res?.commands_isolation]) + return JSON.stringify([actor, chat, res?.commands_provider, v.generation, v.incarnation, res?.commands, v.readiness, v.isolation || res?.commands_isolation, v.unknown_reason]) } diff --git a/core/http/react-ui/tests/unit/chatcommands.test.mjs b/core/http/react-ui/tests/unit/chatcommands.test.mjs index 8c7704d50..458adf0c5 100644 --- a/core/http/react-ui/tests/unit/chatcommands.test.mjs +++ b/core/http/react-ui/tests/unit/chatcommands.test.mjs @@ -269,7 +269,7 @@ for (const provider of ['copper-runner', 'violet-executor']) test(`typed safety: }) const composer = hookHarness('../../src/components/chat/ChatComposer.jsx', 'ChatComposer', { useTranslation:() => ({t:key => key}), useAuth:() => ({isAdmin:true,user:{id:'alice'}}), - useModelFit:() => ({}), useCommandsNotice:() => ({info:null}), + useModelFit:() => ({}), useCommandsNotice:() => ({info:null}), unknownTextKey: imports.unknownTextKey, // Only the original code needs these; keeping them permits a genuine RED run. SANDBOX_ADDON:'sandbox', useSandboxStatus:() => null, }, source => source.replace('export default function', 'function').split(' // What a row of the model list')[0] + 'return shellNote\n}') @@ -303,3 +303,20 @@ for (const provider of ['copper-runner', 'violet-executor']) test(`typed safety: group.providers.bash = 'builtin' assert.equal(render('bob:3', {state:'host',provider:'builtin'}).warn, true) }) + +test('unknown status: each reason code has its own text key, and a code from a newer server falls back', async () => { + const { commandsState, unknownTextKey } = await import('../../src/utils/chatCommands.js') + const locale = JSON.parse((await import('node:fs')).readFileSync(new URL('../../public/locales/en/chats.json', import.meta.url), 'utf8')) + for (const code of ['owner_unreachable', 'not_reported', 'stale', 'invalid_status', 'not_selected', 'no_answer', 'reported_unknown']) { + const res = { commands: 'available', commands_provider: 'sandbox', execution_status: { readiness: 'unknown', isolation: 'unknown', unknown_reason: code } } + const state = commandsState(res) + assert.equal(state.state, 'unknown') + assert.equal(state.code, code) + const info = commandsNotice('unknown', { code: state.code }) + assert.equal(info.textKey, `unknownBy.${code}`) + assert.ok(locale.tools.commands.unknownBy[code], `missing text for ${code}`) + } + assert.equal(unknownTextKey('from_the_future'), 'unknown') + assert.equal(unknownTextKey(''), 'unknown') + assert.equal(commandsState({ commands: 'available', commands_provider: 'sandbox', execution_status: { readiness: 'unknown', isolation: 'unknown', unknown_reason: 'x' } }).code, '') +}) diff --git a/core/schema/chats.go b/core/schema/chats.go index 568943385..6bc99966e 100644 --- a/core/schema/chats.go +++ b/core/schema/chats.go @@ -1622,8 +1622,10 @@ type ChatsEgress struct { Preset string `json:"preset"` // off, registries, all Ceiling string `json:"ceiling"` // the administrator's limit Effective string `json:"effective"` // the lower of the two - // Applies is false when commands run on this machine (applies_reason - // host) or no Sandbox serves bash (none). + // Applies is true when the add-on that runs the commands reports that it + // enforces the network policy. It is false when commands run on this + // machine (applies_reason host), or no Sandbox serves bash or the add-on + // does not report enforcement (none). Applies bool `json:"applies"` AppliesReason string `json:"applies_reason,omitempty"` // Addon is the add-on that runs the chat's commands and carries the @@ -1697,6 +1699,10 @@ type ChatsExecutionStatus struct { Readiness string `json:"readiness"` Isolation string `json:"isolation"` Explanation string `json:"explanation,omitempty"` - Generation string `json:"generation,omitempty"` - Incarnation string `json:"incarnation,omitempty"` + // UnknownReason says why the status is unknown: owner_unreachable, + // not_reported, stale, invalid_status, not_selected, no_answer or + // reported_unknown. Absent when the status is known. + UnknownReason string `json:"unknown_reason,omitempty" example:"owner_unreachable"` + Generation string `json:"generation,omitempty"` + Incarnation string `json:"incarnation,omitempty"` } diff --git a/core/services/studio/addonskills.go b/core/services/studio/addonskills.go index 00ec8507a..e74a6a94c 100644 --- a/core/services/studio/addonskills.go +++ b/core/services/studio/addonskills.go @@ -61,6 +61,23 @@ 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 []string + // documentsWhy is why the Sandbox is not known to be isolated. + documentsWhy string +} + +// withNotes adds to notes the line that says why the Sandbox 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 " + +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) } // addonSkillsFor lists the add-ons of the user that ship skills and decides @@ -102,6 +119,10 @@ func (s *Service) addonSkillsFor(ctx context.Context, p string, addonsOff []stri continue } if set.AddonID == "sandbox" && !sandboxDocumentEligible(command) { + if command.State == CommandsAvailable && command.Provider == "sandbox" { + 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) })...) + } set.Skills = slices.DeleteFunc(slices.Clone(set.Skills), contract.DocumentSkill) set.Shadows = slices.DeleteFunc(slices.Clone(set.Shadows), contract.DocumentSkill) } @@ -217,3 +238,23 @@ 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 +// 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" { + return "its commands run on the server without isolation" + } + value, d := ObserveExecution(ctx, p, command.Addon) + if value.Readiness == "unavailable" { + return "the Sandbox reports that it cannot run commands" + } + why := d.Failure.Text() + if d.Failure == "" { + why = "its status says isolation is unknown" + } + if d.Explanation != "" { + why += " (" + d.Explanation + ")" + } + return "its status could not be read: " + why +} diff --git a/core/services/studio/capabilities_test.go b/core/services/studio/capabilities_test.go index c4b090d8a..39162b35d 100644 --- a/core/services/studio/capabilities_test.go +++ b/core/services/studio/capabilities_test.go @@ -243,7 +243,7 @@ type presentationAddons struct { bound bool } -func (p presentationAddons) NewExecutionObservation(ctx context.Context, user, conv string) (*executionrequest.Request, string, func() string) { +func (p presentationAddons) NewExecutionObservation(ctx context.Context, user, conv string) (*executionrequest.Request, string, func() studio.ExecutionDetail) { if !p.bound { return nil, "", nil } @@ -255,7 +255,7 @@ func (p presentationAddons) NewExecutionObservation(ctx context.Context, user, c }, }, nil) Expect(r.Binding()).To(Equal(b)) - return r, b.Addon, func() string { return "plain explanation" } + return r, b.Addon, func() studio.ExecutionDetail { return studio.ExecutionDetail{Explanation: "plain explanation"} } } var _ = Describe("execution presentation DTO", func() { diff --git a/core/services/studio/documenteligibility_internal_test.go b/core/services/studio/documenteligibility_internal_test.go index 97a0aa480..0cb4ae88d 100644 --- a/core/services/studio/documenteligibility_internal_test.go +++ b/core/services/studio/documenteligibility_internal_test.go @@ -28,6 +28,30 @@ var _ = Describe("document command eligibility", func() { Expect(err).NotTo(HaveOccurred()) Expect(skills.Names(skills.Usable(list))).NotTo(ContainElement("docx")) }) + DescribeTable("tells the person why the Sandbox document skills are missing", func(command Commands, off []string, wantNote bool) { + ctx := context.Background() + b := &skillBroker{} + svc, f := newSkillService(b) + Expect(f.SyncAddonSkills(ctx, "sandbox", fstest.MapFS{ + "docx/SKILL.md": {Data: md("docx", "Sandbox document.")}, + "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}) + _, 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; "))) + } else { + Expect(strings.Join(notes, ";")).NotTo(ContainSubstring("isolated Sandbox")) + } + }, + 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("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("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") @@ -174,6 +198,40 @@ var _ = Describe("document provider matrix", func() { Expect(saved.Skills).To(Equal([]string{"docx"})) }, Entry("permitted then revoked Sandbox", true), Entry("absent Sandbox grant", false)) + 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"}}} + 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}) + 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"}) + Expect(err).NotTo(HaveOccurred()) + rec := session.ChatRecord{ID: "slides-child", Principal: "alice", Model: "m", Kind: session.KindSubAgent, ParentID: parent.ID, AgentID: agent.ID} + Expect(svc.d.Store.CreateChat(ctx, rec)).To(Succeed()) + x, err := svc.ChatAccess(ctx, "alice", rec.ID) + Expect(err).NotTo(HaveOccurred()) + turn, err := svc.chatTools(ctx, x) + Expect(err).NotTo(HaveOccurred()) + var loader session.Tool + for _, tool := range turn.Tools { + if tool.Name == "load_skill" { + loader = tool + } + } + Expect(loader.Run).NotTo(BeNil(), "the tool is offered while the status reads as isolated") + // The status can no longer be read when the model calls the tool. + b.isolation = "" + _, 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")) + }) It("opens a scoped chat with load_skill without reentering session access resolution", func() { ctx := context.Background() // A stub harness: the spec checks that Open builds the chat tools @@ -304,3 +362,10 @@ func documentCommands(s *Service, ctx context.Context, p string, rec session.Cha Expect(err).NotTo(HaveOccurred()) return c } + +// chatSkillNotes resolves the skills of a chat as the turn does and returns +// the notes it gets. +func chatSkillNotes(svc *Service, ctx context.Context, off []string, command Commands) ([]skills.Skill, []string, error) { + list, notes, _, err := svc.chatSkillSet(ctx, "alice", nil, off, nil, command) + return list, notes, err +} diff --git a/core/services/studio/egress_test.go b/core/services/studio/egress_test.go index 9693fd8cb..e37c587fe 100644 --- a/core/services/studio/egress_test.go +++ b/core/services/studio/egress_test.go @@ -9,9 +9,11 @@ import ( "github.com/mudler/LocalAI/core/services/studio" "github.com/mudler/LocalAI/core/services/studio/egress" "github.com/mudler/LocalAI/core/services/studio/egress/audit" + "github.com/mudler/LocalAI/core/services/studio/executionrequest" "github.com/mudler/LocalAI/core/services/studio/store" "github.com/mudler/LocalAI/core/services/studio/studiotest" "github.com/mudler/LocalAI/core/services/studio/workspace" + model "github.com/mudler/LocalAI/pkg/addons/model/executionstatus" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -222,6 +224,73 @@ func (c controlledAddons) Controls(_ context.Context, _, addonID string) []strin return c.controls[addonID] } +// reportingAddons is a broker whose runner reports its typed execution status. +type reportingAddons struct { + controlledAddons + status model.Value + // enforced is what the runner says about the network policy. + enforced bool +} + +func (r reportingAddons) NewExecutionObservation(ctx context.Context, user, _ string) (*executionrequest.Request, string, func() studio.ExecutionDetail) { + b := model.Binding{Actor: user, Addon: "sandbox", Generation: "1", Incarnation: "one", ExecutionTool: "bash", StatusTool: "status"} + req := 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(r.status), nil + }, + }, nil) + return req, b.Addon, func() studio.ExecutionDetail { return studio.ExecutionDetail{NetworkPolicyEnforced: r.enforced} } +} + +func (r reportingAddons) Isolation(ctx context.Context, user, addonID string) string { + v, _ := studio.ExecutionObservation(ctx, user, addonID) + if v.Readiness != model.Available { + return "" + } + return string(v.Isolation) +} + +var _ = Describe("whether the network settings apply to the commands of a chat", func() { + egressOf := func(status model.Value, enforced bool, controls ...string) studio.EgressView { + fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: runAddon()}} + f := newFiles() + svc, _ := newToolService(func(workspace.Actor, string) []studiotest.ToolStep { return nil }, func(d *studio.Deps) { + d.Files = f + d.AddonTools = reportingAddons{controlledAddons{isolatedAddons{fa, "isolated"}, map[string][]string{"sandbox": controls}}, status, enforced} + }) + v, err := svc.Egress(context.Background(), alice, create(svc, alice)) + Expect(err).NotTo(HaveOccurred()) + return v + } + isolated := model.Value{Readiness: model.Available, Isolation: model.Isolated} + + It("applies when the isolated runner reports that it enforces the policy", func() { + v := egressOf(isolated, true, "network-policy") + Expect(v.Applies).To(BeTrue()) + Expect(v.AppliesReason).To(BeEmpty()) + Expect(v.JSON().Addon).To(Equal("sandbox")) + }) + It("does not apply when the runner does not report enforcement", func() { + v := egressOf(isolated, false, "network-policy") + Expect(v.Applies).To(BeFalse()) + Expect(v.AppliesReason).To(Equal(studio.EgressAppliesNone)) + }) + It("does not trust an enforcement report from a runner that is not isolated", func() { + v := egressOf(model.Value{Readiness: model.Available, Isolation: model.Host}, true, "network-policy") + Expect(v.Applies).To(BeFalse()) + }) + It("does not trust an enforcement report from a runner that is not available", func() { + v := egressOf(model.Value{Readiness: model.Unavailable}, true, "network-policy") + Expect(v.Applies).To(BeFalse()) + }) + It("applies but draws no row without the network-policy control", func() { + v := egressOf(isolated, true, "tool-providers") + Expect(v.Applies).To(BeTrue()) + Expect(v.JSON().Addon).To(BeEmpty()) + }) +}) + var _ = Describe("the add-on that draws the network row of a chat", func() { runner := func(addonID string) []studio.AddonTool { bash := addonTool(addonID, "bash") diff --git a/core/services/studio/egressapi.go b/core/services/studio/egressapi.go index 92d87c48c..e27ddcd2c 100644 --- a/core/services/studio/egressapi.go +++ b/core/services/studio/egressapi.go @@ -28,19 +28,21 @@ var ( // The reasons the network settings do not apply to a chat. const ( EgressAppliesHost = "host" // commands run on this machine, with its network - EgressAppliesNone = "none" // no Sandbox serves bash in this chat + EgressAppliesNone = "none" // no Sandbox serves bash, or it does not report that it enforces the policy ) // EgressView is the network of one chat, as its network panel shows it. type EgressView struct { Preset, Ceiling, Effective egress.Preset - // Applies is false when commands run on this machine or no Sandbox serves - // bash; AppliesReason then says which. + // Applies is true when the provider that runs the commands reports that it + // enforces the network policy. It is false when commands run on this + // machine, no Sandbox serves bash, or the provider does not report + // enforcement; AppliesReason then says which. Applies bool AppliesReason string // Addon is the add-on that runs the chat's commands and carries the // network-policy host control, which draws the network row of the chat: - // set only while Applies. Without it no add-on draws the controls, and + // set only for a provider that carries it. Without it no add-on draws the controls, and // the policy still holds. Addon string Registries []egress.Registry @@ -118,8 +120,13 @@ func (s *Service) Egress(ctx context.Context, user, conv string) (EgressView, er case cmds.State == CommandsAvailable && (cmds.Provider == BuiltinProvider || cmds.Isolation == "host"): v.AppliesReason = EgressAppliesHost case cmds.State == CommandsAvailable: - // Provider isolation does not establish host network-policy enforcement. - v.AppliesReason = EgressAppliesNone + // Isolation alone does not establish network-policy enforcement: the + // settings apply only when the provider reports that it enforces them + // in a status that is available and isolated. + v.Applies = cmds.ExecutionStatus != nil && cmds.ExecutionStatus.NetworkPolicyEnforced + if !v.Applies { + v.AppliesReason = EgressAppliesNone + } if c, ok := s.d.AddonTools.(AddonControls); ok && slices.Contains(c.Controls(ctx, p, cmds.Provider), contract.ControlNetworkPolicy) { v.Addon = cmds.Provider } diff --git a/core/services/studio/executionobservation.go b/core/services/studio/executionobservation.go index 4cab34084..a022d4873 100644 --- a/core/services/studio/executionobservation.go +++ b/core/services/studio/executionobservation.go @@ -2,8 +2,10 @@ package studio import ( + "cmp" "context" "sync" + "time" "github.com/mudler/LocalAI/core/services/studio/executionrequest" "github.com/mudler/LocalAI/core/services/studio/session" @@ -13,18 +15,59 @@ import ( // ExecutionObserver admits the owner-selected provider, not a discovery claim. // The request and explanation reader belong exclusively to this outer scope. type ExecutionObserver interface { - NewExecutionObservation(context.Context, string, string) (*executionrequest.Request, string, func() string) + // The last result reads what the provider reported beside the typed value, + // or why there is none. It is never nil for a nil request that failed. + NewExecutionObservation(context.Context, string, string) (*executionrequest.Request, string, func() ExecutionDetail) +} + +// UnknownReason says why the host has no execution status for a chat. The +// codes reach the API and the chat page, which words them. +type UnknownReason string + +const ( + // UnknownOwnerUnreachable: the process that owns the add-on did not answer. + UnknownOwnerUnreachable UnknownReason = "owner_unreachable" + // UnknownNotReported: the add-on declares no execution status to read. + UnknownNotReported UnknownReason = "not_reported" + // UnknownStale: the add-on changed (version or runtime) while it was read. + UnknownStale UnknownReason = "stale" + // UnknownInvalid: the add-on answered with a status the host refuses. + UnknownInvalid UnknownReason = "invalid_status" + // UnknownNotSelected: the add-on does not run this chat's commands. + UnknownNotSelected UnknownReason = "not_selected" + // UnknownNoAnswer: the add-on did not answer in time. + UnknownNoAnswer UnknownReason = "no_answer" + // UnknownReportedUnknown: the add-on answered that it cannot tell. + UnknownReportedUnknown UnknownReason = "reported_unknown" +) + +// ExecutionDetail is what the provider said beside the typed value, plus why +// no status could be read. Failure is meaningful only while the value is +// unknown. +type ExecutionDetail struct { + Explanation string + // NetworkPolicyEnforced is the provider's report; the host trusts it only + // for an available, isolated value. + NetworkPolicyEnforced bool + Failure UnknownReason } type executionObservationKey struct{} type executionObservation struct { ctx context.Context user, conversation string - once sync.Once - request *executionrequest.Request - addon string - explanation func() string - provider ExecutionObserver + // mu guards the fields below. A scope that is closed never starts again. + mu sync.Mutex + closed bool + started bool + attempted time.Time + // retriable is set when the last observation failed in a way a fresh one + // may fix (the owner restarted or did not answer). + retriable bool + request *executionrequest.Request + addon string + detail func() ExecutionDetail + provider ExecutionObserver } // executionScope borrows existing outer state. Nested consumers must not close @@ -38,7 +81,9 @@ func (s *Service) executionScope(ctx context.Context, user, conversation string) scope := &executionObservation{ctx: ownerCtx, user: user, conversation: conversation, provider: provider} return context.WithValue(ctx, executionObservationKey{}, scope), func() { cancel() - scope.once.Do(func() {}) + scope.mu.Lock() + defer scope.mu.Unlock() + scope.closed = true if scope.request != nil { scope.request.Close() } @@ -52,24 +97,94 @@ func (s *Service) productScope(ctx context.Context, x session.Access) (context.C // ExecutionObservation never falls back to an arbitrary installed provider. // An out-of-scope, wrong-user or non-selected add-on has unknown status. func ExecutionObservation(ctx context.Context, user, addon string) (model.Value, string) { + value, detail := ObserveExecution(ctx, user, addon) + return value, detail.Explanation +} + +// ObserveExecution is ExecutionObservation with the whole detail. Failure says +// why the value is unknown; Explanation and NetworkPolicyEnforced are set only +// when the provider answered. +func ObserveExecution(ctx context.Context, user, addon string) (model.Value, ExecutionDetail) { scope, ok := ctx.Value(executionObservationKey{}).(*executionObservation) if !ok || user != scope.user { - return model.Unknown(), "" + return model.Unknown(), ExecutionDetail{Failure: UnknownNotSelected} } - scope.once.Do(func() { - if scope.provider != nil { - scope.request, scope.addon, scope.explanation = scope.provider.NewExecutionObservation(scope.ctx, scope.user, scope.conversation) + scope.mu.Lock() + if !scope.closed && scope.provider != nil && (!scope.started || (scope.retriable && time.Since(scope.attempted) >= observationRetryAfter)) { + // The first read of a scope stands. A turn can outlive a restart of the + // add-on owner, so after a failure a restart may fix, one fresh + // observation (a new binding and a new read) is allowed per interval. + if scope.request != nil { + scope.request.Close() } - }) - if scope.request == nil || addon != scope.addon { - return model.Unknown(), "" + scope.request, scope.addon, scope.detail = scope.provider.NewExecutionObservation(scope.ctx, scope.user, scope.conversation) + scope.started, scope.attempted, scope.retriable = true, time.Now(), false } - value := scope.request.Observe(ctx) - explanation := "" - if value.Readiness != model.UnknownReadiness && scope.explanation != nil { - explanation = scope.explanation() + request, scopeAddon, detailOf := scope.request, scope.addon, scope.detail + scope.mu.Unlock() + read := func() ExecutionDetail { + if detailOf == nil { + return ExecutionDetail{} + } + return detailOf() } - return value, explanation + settle := func(v model.Value, d ExecutionDetail) (model.Value, ExecutionDetail) { + scope.mu.Lock() + scope.retriable = v.Readiness == model.UnknownReadiness && d.Failure.retriable() + scope.mu.Unlock() + return v, d + } + if request == nil { + d := read() + return settle(model.Unknown(), ExecutionDetail{Failure: cmp.Or(d.Failure, UnknownNotReported)}) + } + if addon != scopeAddon { + return model.Unknown(), ExecutionDetail{Failure: UnknownNotSelected} + } + value := request.Observe(ctx) + d := read() + if value.Readiness == model.UnknownReadiness { + failure := cmp.Or(d.Failure, UnknownReportedUnknown) + out := ExecutionDetail{Failure: failure} + if failure == UnknownReportedUnknown { + out.Explanation = d.Explanation + } + return settle(value, out) + } + d.Failure = "" + return value, d +} + +// observationRetryAfter is the least time between two observations of one +// scope. A test may lower it. +var observationRetryAfter = 5 * time.Second + +// retriable says whether a fresh observation may succeed where this failure +// did. A refusal of the binding or of the payload will not change by itself. +func (r UnknownReason) retriable() bool { + return r == UnknownOwnerUnreachable || r == UnknownNoAnswer || r == UnknownStale +} + +// Text is the reason in a sentence for a person or a model, without the +// subject: "its status could not be read: " + Text(). +func (r UnknownReason) Text() string { + switch r { + case UnknownOwnerUnreachable: + return "the add-on owner could not be reached" + case UnknownNotReported: + return "the add-on has not reported its execution status" + case UnknownStale: + return "the status did not match the add-on's current version" + case UnknownInvalid: + return "the add-on sent a status the host could not accept" + case UnknownNotSelected: + return "the add-on is not the one that runs this chat's commands" + case UnknownNoAnswer: + return "the add-on did not answer in time" + case UnknownReportedUnknown: + return "the add-on reported that it cannot tell" + } + return "the reason is not known" } // ExecutionStatusView is observation data, never execution authorization. @@ -77,20 +192,32 @@ type ExecutionStatusView struct { Readiness string `json:"readiness"` Isolation string `json:"isolation"` Explanation string `json:"explanation,omitempty"` - Generation string `json:"generation,omitempty"` - Incarnation string `json:"incarnation,omitempty"` + // UnknownReason is set while the status is unknown: why the host could not + // read one. + UnknownReason string `json:"unknown_reason,omitempty"` + // NetworkPolicyEnforced is true only when the provider reported that its + // commands reach the network through the host's policy and the status is + // available and isolated. The host uses it; the page does not. + NetworkPolicyEnforced bool `json:"-"` + Generation string `json:"generation,omitempty"` + Incarnation string `json:"incarnation,omitempty"` } func executionStatusView(ctx context.Context, user, addon string) *ExecutionStatusView { - v, explanation := model.Unknown(), "" + v, d := model.Unknown(), ExecutionDetail{Failure: UnknownNotSelected} if addon != "" { - v, explanation = ExecutionObservation(ctx, user, addon) + v, d = ObserveExecution(ctx, user, addon) } - out := &ExecutionStatusView{Readiness: string(v.Readiness), Isolation: string(v.Isolation), Explanation: explanation} + out := &ExecutionStatusView{Readiness: string(v.Readiness), Isolation: string(v.Isolation), Explanation: d.Explanation, UnknownReason: string(d.Failure), + NetworkPolicyEnforced: d.NetworkPolicyEnforced && v.Readiness == model.Available && v.Isolation == model.Isolated} scope, ok := ctx.Value(executionObservationKey{}).(*executionObservation) - if ok && scope.user == user && scope.addon == addon && scope.request != nil { - b := scope.request.Binding() - out.Generation, out.Incarnation = b.Generation, b.Incarnation + if ok && scope.user == user { + scope.mu.Lock() + if scope.addon == addon && scope.request != nil { + b := scope.request.Binding() + out.Generation, out.Incarnation = b.Generation, b.Incarnation + } + scope.mu.Unlock() } return out } diff --git a/core/services/studio/executionobservation_test.go b/core/services/studio/executionobservation_test.go index bb51e7085..519045bf1 100644 --- a/core/services/studio/executionobservation_test.go +++ b/core/services/studio/executionobservation_test.go @@ -26,7 +26,7 @@ type observationProvider struct { fail bool } -func (p *observationProvider) NewExecutionObservation(ctx context.Context, user, conv string) (*executionrequest.Request, string, func() string) { +func (p *observationProvider) NewExecutionObservation(ctx context.Context, user, conv string) (*executionrequest.Request, string, func() ExecutionDetail) { b := model.Binding{Actor: user, Addon: "renamed-provider", Generation: "1", Incarnation: "one", ExecutionTool: "run", StatusTool: "inspect"} r := executionrequest.New(ctx, b, "host-q", model.Eligible, executionrequest.IO{ Validate: func(context.Context, model.Binding) error { return nil }, @@ -50,7 +50,7 @@ func (p *observationProvider) NewExecutionObservation(ctx context.Context, user, if p.requests != nil { p.requests <- r } - return r, b.Addon, func() string { return "" } + return r, b.Addon, func() ExecutionDetail { return ExecutionDetail{} } } var _ = Describe("execution status product scope", func() { @@ -159,3 +159,144 @@ var _ = Describe("execution status product scope", func() { Expect(v.Readiness != "unknown" || v.Isolation != "unknown" || v.Generation != "" || v.Incarnation != "").To(BeFalse(), "unbound status manufactured identity", v) }) }) + +// reasonProvider returns a fixed observation, or none with a failure. +type reasonProvider struct { + AddonTools + request *executionrequest.Request + addon string + detail func() ExecutionDetail +} + +func (p reasonProvider) NewExecutionObservation(context.Context, string, string) (*executionrequest.Request, string, func() ExecutionDetail) { + return p.request, p.addon, p.detail +} + +var _ = Describe("why an execution status is unknown", func() { + view := func(p reasonProvider, addon string) *ExecutionStatusView { + s := &Service{d: Deps{AddonTools: p}} + ctx, done := s.executionScope(context.Background(), "alice", "chat") + DeferCleanup(done) + return executionStatusView(ctx, "alice", addon) + } + request := func(payload model.Payload, err error) *executionrequest.Request { + b := model.Binding{Actor: "alice", Addon: "runner", Generation: "1", Incarnation: "one", ExecutionTool: "run", StatusTool: "inspect"} + r := executionrequest.New(context.Background(), 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 payload, err + }}, nil) + DeferCleanup(r.Close) + return r + } + + It("carries the failure the provider recorded when it could not admit a request", func() { + for _, reason := range []UnknownReason{UnknownOwnerUnreachable, UnknownNotReported, UnknownStale} { + v := view(reasonProvider{detail: func() ExecutionDetail { return ExecutionDetail{Failure: reason} }}, "runner") + Expect(v.Readiness).To(Equal("unknown")) + Expect(v.UnknownReason).To(Equal(string(reason))) + } + }) + It("says the status was not reported when the provider recorded nothing", func() { + Expect(view(reasonProvider{}, "runner").UnknownReason).To(Equal(string(UnknownNotReported))) + }) + It("says the add-on does not run the chat's commands when another add-on was admitted", func() { + p := reasonProvider{request: request(model.ValidPayload(model.Value{Readiness: model.Available, Isolation: model.Isolated}), nil), addon: "runner", detail: func() ExecutionDetail { return ExecutionDetail{} }} + Expect(view(p, "other").UnknownReason).To(Equal(string(UnknownNotSelected))) + Expect(view(p, "").UnknownReason).To(Equal(string(UnknownNotSelected))) + }) + It("carries the failure of a read the add-on did not answer", func() { + p := reasonProvider{request: request(model.InvalidPayload(), errors.New("boom")), addon: "runner", + detail: func() ExecutionDetail { return ExecutionDetail{Failure: UnknownInvalid} }} + Expect(view(p, "runner").UnknownReason).To(Equal(string(UnknownInvalid))) + }) + It("says the add-on reported unknown when it answered unknown", func() { + p := reasonProvider{request: request(model.ValidPayload(model.Unknown()), nil), addon: "runner", detail: func() ExecutionDetail { return ExecutionDetail{} }} + Expect(view(p, "runner").UnknownReason).To(Equal(string(UnknownReportedUnknown))) + }) + It("gives no reason once the status is known, and trusts enforcement only for available isolated", func() { + for value, want := range map[model.Value]bool{ + {Readiness: model.Available, Isolation: model.Isolated}: true, + {Readiness: model.Available, Isolation: model.Host}: false, + } { + p := reasonProvider{request: request(model.ValidPayload(value), nil), addon: "runner", + detail: func() ExecutionDetail { return ExecutionDetail{NetworkPolicyEnforced: true, Failure: UnknownStale} }} + v := view(p, "runner") + Expect(v.UnknownReason).To(BeEmpty()) + Expect(v.NetworkPolicyEnforced).To(Equal(want)) + } + }) +}) + +// flakyProvider fails to admit its first observations, then succeeds. +type flakyProvider struct { + AddonTools + failures int + failure UnknownReason + calls atomic.Int32 +} + +func (p *flakyProvider) NewExecutionObservation(ctx context.Context, user, _ string) (*executionrequest.Request, string, func() ExecutionDetail) { + if int(p.calls.Add(1)) <= p.failures { + return nil, "", func() ExecutionDetail { return ExecutionDetail{Failure: p.failure} } + } + b := model.Binding{Actor: user, Addon: "runner", Generation: "2", Incarnation: "two", ExecutionTool: "run", StatusTool: "inspect"} + 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.Isolated}), nil + }}, nil) + return r, b.Addon, func() ExecutionDetail { return ExecutionDetail{} } +} + +var _ = Describe("observing again after the add-on owner restarted", func() { + scopeOf := func(p ExecutionObserver) context.Context { + s := &Service{d: Deps{AddonTools: p.(AddonTools)}} + ctx, done := s.executionScope(context.Background(), "alice", "chat") + DeferCleanup(done) + return ctx + } + BeforeEach(func() { + old := observationRetryAfter + observationRetryAfter = 20 * time.Millisecond + DeferCleanup(func() { observationRetryAfter = old }) + }) + + It("keeps the first answer inside the interval, then binds and reads again", func() { + p := &flakyProvider{failures: 1, failure: UnknownOwnerUnreachable} + ctx := scopeOf(p) + v, d := ObserveExecution(ctx, "alice", "runner") + Expect(v).To(Equal(model.Unknown())) + Expect(d.Failure).To(Equal(UnknownOwnerUnreachable)) + _, _ = ObserveExecution(ctx, "alice", "runner") + Expect(p.calls.Load()).To(BeEquivalentTo(1), "no second admission within the interval") + time.Sleep(2 * observationRetryAfter) + v, d = ObserveExecution(ctx, "alice", "runner") + Expect(v.Isolation).To(Equal(model.Isolated)) + Expect(d.Failure).To(BeEmpty()) + Expect(p.calls.Load()).To(BeEquivalentTo(2)) + _, _ = ObserveExecution(ctx, "alice", "runner") + Expect(p.calls.Load()).To(BeEquivalentTo(2), "a good status is not read again") + }) + It("does not retry a refusal that waiting will not fix", func() { + p := &flakyProvider{failures: 1, failure: UnknownNotReported} + ctx := scopeOf(p) + _, _ = ObserveExecution(ctx, "alice", "runner") + time.Sleep(2 * observationRetryAfter) + v, d := ObserveExecution(ctx, "alice", "runner") + Expect(v).To(Equal(model.Unknown())) + Expect(d.Failure).To(Equal(UnknownNotReported)) + Expect(p.calls.Load()).To(BeEquivalentTo(1)) + }) + It("does not start again once the scope is closed", func() { + p := &flakyProvider{failures: 1, failure: UnknownStale} + s := &Service{d: Deps{AddonTools: p}} + ctx, done := s.executionScope(context.Background(), "alice", "chat") + _, _ = ObserveExecution(ctx, "alice", "runner") + done() + time.Sleep(2 * observationRetryAfter) + v, _ := ObserveExecution(ctx, "alice", "runner") + Expect(v).To(Equal(model.Unknown())) + Expect(p.calls.Load()).To(BeEquivalentTo(1)) + }) +}) diff --git a/core/services/studio/loadskill.go b/core/services/studio/loadskill.go index 9e9d16129..1e7c3c991 100644 --- a/core/services/studio/loadskill.go +++ b/core/services/studio/loadskill.go @@ -14,6 +14,7 @@ import ( "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" ) // loadSkillName is the name of the tool that loads a skill by its name. @@ -107,33 +108,45 @@ func (s *Service) loadableSkills(ctx context.Context, a workspace.Actor) ([]skil } func (s *Service) loadableSkillsWithCommands(ctx context.Context, a workspace.Actor, final *bool) ([]skills.Skill, error) { + list, _, err := s.loadableSkillsAndNotes(ctx, a, final) + return list, err +} + +// loadableSkillsAndNotes also returns the notes of the resolution, which say +// why a skill is missing. +func (s *Service) loadableSkillsAndNotes(ctx context.Context, a workspace.Actor, final *bool) ([]skills.Skill, []string, error) { access, err := s.AddonAccess(ctx, a.Principal, a.ChatID) if err != nil { - return nil, err + return nil, nil, err } - return s.loadableSkillsForActor(ctx, access.Actor(), final) + return s.loadableSkillsAndNotesForActor(ctx, access.Actor(), final) } // loadableSkillsForActor is for initial tool construction, using its already // authorized actor. Public picker and tool calls resolve fresh access instead. func (s *Service) loadableSkillsForActor(ctx context.Context, a workspace.Actor, final *bool) ([]skills.Skill, error) { + list, _, err := s.loadableSkillsAndNotesForActor(ctx, a, final) + return list, err +} + +func (s *Service) loadableSkillsAndNotesForActor(ctx context.Context, a workspace.Actor, final *bool) ([]skills.Skill, []string, error) { rec, err := s.d.Store.GetChat(ctx, a.Principal, a.ChatID) if err != nil { - return nil, err + return nil, nil, err } sc := s.scopeOf(ctx, rec) command, err := s.addonCommandDescriptorForActor(ctx, a, rec, sc) if err != nil { - return nil, err + return nil, nil, err } if final != nil && !*final { command = Commands{State: CommandsNone} } - list, _, _, err := s.chatSkillSet(ctx, a.Principal, rec.SkillsOff, rec.AddonsOff, sc, command) + list, notes, _, err := s.chatSkillSet(ctx, a.Principal, rec.SkillsOff, rec.AddonsOff, sc, command) if err != nil { - return nil, err + return nil, nil, err } - return skills.Usable(list), nil + return skills.Usable(list), notes, nil } // loadSkillRun is the Run of load_skill. It resolves the skills again at each @@ -147,10 +160,18 @@ func (s *Service) loadSkillRun(a workspace.Actor, o loadOpts) func(context.Conte if err != nil { return "", err } - list, err := s.loadableSkillsWithCommands(ctx, a, o.sandbox) + list, notes, err := s.loadableSkillsAndNotes(ctx, a, o.sandbox) 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 + // 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 { + return "", fmt.Errorf("unknown skill %q: %s", skills.Clean(in.Name), notes[i]) + } + } return s.loadSkill(ctx, a.Principal, list, in.Name, o) } } diff --git a/core/services/studio/skillsvc.go b/core/services/studio/skillsvc.go index d05f8ba7d..0be4cb62c 100644 --- a/core/services/studio/skillsvc.go +++ b/core/services/studio/skillsvc.go @@ -172,7 +172,7 @@ func (s *Service) chatSkillSet(ctx context.Context, p string, off, addonsOff []s if err != nil { return nil, nil, "", err } - return list, notes, ad.markFallbacks(list), nil + return list, ad.withNotes(notes), ad.markFallbacks(list), nil } // Skills lists the skills the user has: every layer, with shadowed and skipped @@ -211,7 +211,7 @@ func (s *Service) Skills(ctx context.Context, user, conv string) ([]skills.Skill return nil, nil, err } ad.markFallbacks(list) - return list, notes, nil + return list, ad.withNotes(notes), nil } // WellKnown returns the well-known files of the users' homes: persona.json, diff --git a/docs/content/features/addons.md b/docs/content/features/addons.md index d389ef742..1a37209ca 100644 --- a/docs/content/features/addons.md +++ b/docs/content/features/addons.md @@ -235,10 +235,18 @@ The existing result envelope carries this JSON in `payload`: { "readiness": "available", "isolation": "isolated", - "explanation": "Commands use the configured isolated backend." + "explanation": "Commands use the configured isolated backend.", + "networkPolicyEnforced": true } ``` +`networkPolicyEnforced` is optional and false when absent. A provider sets it to +true only when every network connection of its commands goes through the host's +network policy (the Sandbox does, through its proxy). The host honors it only +for a status that is `available` and `isolated`; it then shows the chat's +Network row. Without the report the chat says that network-policy enforcement is +not verified for the provider. + `contract.DecodeExecutionStatus` accepts at most 2048 UTF-8 bytes. Both enums are required: readiness is `available`, `unavailable` or `unknown`; isolation is `isolated`, `host` or `unknown`. Optional explanation is nonempty plain text, @@ -254,7 +262,7 @@ not an addon ID or a private settings payload. Prompt construction, command status and nested egress reads share one observation within a turn. Priming and standalone command-status or egress requests have separate, bounded scopes. Consumer cancellation stops that wait; turn completion cancels the observation. -Failed reads settle as unknown and do not retry within the same scope. Each +A failed read settles as unknown with a reason (`unknown_reason`: `owner_unreachable`, `not_reported`, `stale`, `invalid_status`, `not_selected`, `no_answer` or `reported_unknown`), which the Tools menu shows. It is not retried at once. After a failure that a restart of the add-on owner can cause (`owner_unreachable`, `no_answer`, `stale`), a scope that is still open (a long turn) binds and reads again, at most once every five seconds. A refusal of the binding or of the payload is not retried. Each consumption revalidates the complete owner binding, including conversation, current selection identity and runtime incarnation. @@ -4895,6 +4903,11 @@ Request/session integration shares observations only within a bounded request. ### Execution safety notices +The Sandbox document skills (`docx`, `xlsx`, `pdf`, `pptx`) are offered only +while the Sandbox reports an isolated backend. When it runs the chat's commands +and is not known to be isolated, the skill list and `load_skill` say so, with the +reason the status is unknown, instead of saying the chat has no skills. + The chat Tools menu and command chip describe the actual selected execution provider, not an installed app's name. Any provider that reports host execution gets a no-isolation warning. Unknown readiness or isolation gets an explicit diff --git a/docs/content/features/chats.md b/docs/content/features/chats.md index 14d45ba86..e23a37a8f 100644 --- a/docs/content/features/chats.md +++ b/docs/content/features/chats.md @@ -895,7 +895,7 @@ The routes, for the chat's owner (another user's chat is 404): | Route | What it does | |---|---| -| `GET /api/chats/{id}/egress` | `{preset, ceiling, effective, applies, applies_reason, addon, registries, allowed_hosts, rules, cards}`. `applies` is false when commands run on this machine (`applies_reason` `host`) or no Sandbox serves `bash` (`none`). `addon` is the add-on that runs the commands and draws the network controls of the chat (it has the `network-policy` [host control]({{%relref "features/addons#host-controls" %}})); it is absent when `applies` is false or that add-on has no such control, and the chat then shows no Network row. A rule the ceiling no longer admits has `above_limit`. | +| `GET /api/chats/{id}/egress` | `{preset, ceiling, effective, applies, applies_reason, addon, registries, allowed_hosts, rules, cards}`. `applies` is true when the add-on that serves `bash` reports, in its typed execution status, that it enforces the network policy and the status is available and isolated (the Sandbox does, except on the host backend). It is false when commands run on this machine (`applies_reason` `host`), no Sandbox serves `bash`, or the add-on does not report enforcement (`none`). `addon` is the add-on that runs the commands and draws the network controls of the chat (it has the `network-policy` [host control]({{%relref "features/addons#host-controls" %}})); it is absent when no add-on runs the commands or that add-on has no such control. The chat shows the Network row only when `applies` is true and `addon` is set; otherwise it shows a line that says why the settings do not apply. A rule the ceiling no longer admits has `above_limit`. | | `PUT /api/chats/{id}/egress {preset}` | Sets the preset: `off`, `registries` or `all`. Above the ceiling it is 400 `egress_above_ceiling`. | | `DELETE /api/chats/{id}/egress/rules/{host}/{port}` | Removes an allow or deny rule. | | `POST /api/chats/{id}/egress/consent {id, decision}` | Answers a card: `once`, `chat` or `deny`. Allowing a host inside your network needs an administrator (403). The same answer sent twice is one answer (204, nothing changes). Another answer to a card already answered is 409 `already_answered`. A host the ceiling does not admit refuses the card and gives 400 `egress_above_ceiling` on this answer and on every later one, so a stale card never ends in a conflict loop. In `GET /api/chats/{id}/egress` such a card has `above_ceiling`: no answer can make it work. | diff --git a/pkg/addons/contract/executionstatus.go b/pkg/addons/contract/executionstatus.go index 3301d8ede..645e07ce2 100644 --- a/pkg/addons/contract/executionstatus.go +++ b/pkg/addons/contract/executionstatus.go @@ -17,6 +17,11 @@ type ExecutionStatus struct { Readiness string `json:"readiness"` Isolation string `json:"isolation"` Explanation string `json:"explanation,omitempty"` + // NetworkPolicyEnforced is the provider's report that every network + // connection of its commands goes through the host's network policy. It is + // kept only for an available, isolated provider; the host still decides + // whether to rely on it. + NetworkPolicyEnforced bool `json:"networkPolicyEnforced,omitempty"` } // DecodeExecutionStatus decodes Result.Payload, never the result envelope. @@ -28,7 +33,7 @@ func DecodeExecutionStatus(b []byte) (ExecutionStatus, error) { if !utf8.Valid(b) { return unknown, fmt.Errorf("invalid execution status UTF-8") } - fields, err := exactObjectFields(b, MaxExecutionStatusBytes, "readiness", "isolation", "explanation") + fields, err := exactObjectFields(b, MaxExecutionStatusBytes, "readiness", "isolation", "explanation", "networkPolicyEnforced") if err != nil { return unknown, err } @@ -48,5 +53,8 @@ func DecodeExecutionStatus(b []byte) (ExecutionStatus, error) { if v.Readiness != "available" { v.Isolation = "unknown" } + if v.Readiness != "available" || v.Isolation != "isolated" { + v.NetworkPolicyEnforced = false + } return v, nil } diff --git a/pkg/addons/contract/executionstatus_test.go b/pkg/addons/contract/executionstatus_test.go index beef7afac..f28609dc3 100644 --- a/pkg/addons/contract/executionstatus_test.go +++ b/pkg/addons/contract/executionstatus_test.go @@ -47,6 +47,24 @@ var _ = Describe("Execution status payload", func() { Entry("direction", `{"readiness":"available","isolation":"host","explanation":"a\u202eb"}`), Entry("oversized payload", strings.Repeat(" ", 2049)+`{}`), ) + It("reports network-policy enforcement only for an available isolated provider", func() { + for raw, want := range map[string]bool{ + `{"readiness":"available","isolation":"isolated","networkPolicyEnforced":true}`: true, + `{"readiness":"available","isolation":"isolated","networkPolicyEnforced":false}`: false, + `{"readiness":"available","isolation":"isolated"}`: false, + `{"readiness":"available","isolation":"host","networkPolicyEnforced":true}`: false, + `{"readiness":"unavailable","isolation":"isolated","networkPolicyEnforced":true}`: false, + } { + v, err := DecodeExecutionStatus([]byte(raw)) + Expect(err).NotTo(HaveOccurred(), raw) + Expect(v.NetworkPolicyEnforced).To(Equal(want), raw) + } + }) + It("refuses a non-boolean enforcement claim", func() { + v, err := DecodeExecutionStatus([]byte(`{"readiness":"available","isolation":"isolated","networkPolicyEnforced":"yes"}`)) + Expect(err).To(HaveOccurred()) + Expect(v.NetworkPolicyEnforced).To(BeFalse()) + }) It("bridges validation into the existing pure model boundary", func() { for _, tc := range []struct { raw string