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>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 08:39:30 +00:00
1 parent 024b7196de
commit 9fafea8fdf
26 files changed
+833 -81

No files matched your search

@@ -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())
})
})
+10 -1
View File
@@ -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
+30 -1
View File
@@ -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())
})
})
+113 -10
View File
@@ -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)])
}
+1 -1
View File
@@ -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})
@@ -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')
+12 -1
View File
@@ -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')
@@ -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."
},
@@ -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
+2 -2
View File
@@ -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(() => {
+14 -6
View File
@@ -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])
}
@@ -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, '')
})
+10 -4
View File
@@ -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"`
}
+41
View File
@@ -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
}
+2 -2
View File
@@ -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() {
@@ -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
}
+69
View File
@@ -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")
+13 -6
View File
@@ -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
}
+154 -27
View File
@@ -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
}
@@ -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))
})
})
+29 -8
View File
@@ -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)
}
}
+2 -2
View File
@@ -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,
+15 -2
View File
@@ -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
+1 -1
View File
@@ -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. |
+9 -1
View File
@@ -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
}
@@ -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