mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 12:33:16 -05:00
A saved agent now has a Builtins list: the capabilities its owner gave it (memory.write, profile.write, skills.write, pages.write, dashboard.write, triggers.propose, triggers.run, talkback.channels, talkback.new_channel, files.upload, act.files, act.addons, act.send, act.spend and the opt-in group saved_agents). The list is empty by default, and nothing turns a grant on for a template, an import or a write that omits the field. A chat of the agent holds the grants the user may use at the turn (agent scope). A tool exists when the grant is held, the chat did not switch its group off and the run can hold rights: a sub-agent or a run that started by itself never does. propose_change and revert_change now exist only for a chat that holds the grant of at least one kind, and the schema lists only those kinds. propose_change gains the kinds skill, page and dashboard. A skill is a SKILL.md for the personal or the agent layer, and a skill with locked: true is refused, also as a shadow of a locked personal skill. A page is a set of files checked against the Pages rules and written as one change set. The dashboard is a home.json checked against its schema. All three are re-checked when the owner confirms or edits. GET /api/chats/agents/builtin-grants lists each grant with its title, description and sensitive flag. The agent record, the write body and the export file carry builtins; an old file imports as before and an unknown name is dropped in an import and refused in a write. Assisted-by: Claude:claude-sonnet-5-5 go gofmt swag
417 lines
16 KiB
Go
417 lines
16 KiB
Go
package studio
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/services/studio/builtins"
|
|
"github.com/mudler/LocalAI/core/services/studio/hostexec"
|
|
"github.com/mudler/LocalAI/core/services/studio/session"
|
|
"github.com/mudler/LocalAI/core/services/studio/store"
|
|
"github.com/mudler/LocalAI/core/services/studio/workspace"
|
|
)
|
|
|
|
// The built-in bash runs commands directly on this machine, with no isolation
|
|
// and with the network of the server (see hostexec). It serves when no Sandbox
|
|
// add-on does: none is installed, it is switched off for the chat, stopped, or
|
|
// cannot run. An administrator chooses with chats_host_exec who gets it: off,
|
|
// ask (administrators, and each command waits for their yes in the chat) or
|
|
// on (everyone, no card).
|
|
|
|
// HostExec runs the built-in bash. *hostexec.Runner implements it.
|
|
type HostExec interface {
|
|
// Unavailable says why this server cannot run commands, or "".
|
|
Unavailable() string
|
|
// Tool is the tool definition of a chat.
|
|
Tool(a workspace.Actor) session.Tool
|
|
}
|
|
|
|
var _ HostExec = (*hostexec.Runner)(nil)
|
|
|
|
// HostExecCardKind is the card of a call of the built-in bash. Its ID says
|
|
// what happened: ran (no card was needed), once (the person allowed it),
|
|
// denied, timeout (nobody answered), admin (only an administrator can allow
|
|
// it). The interface shows it on the tool line, so a reload still says that
|
|
// the command ran on this machine and who allowed it.
|
|
const HostExecCardKind = "host_exec"
|
|
|
|
// The answers of the consent card.
|
|
const (
|
|
HostExecOnce = "once"
|
|
HostExecDeny = "deny"
|
|
)
|
|
|
|
// HostExecDecision is the answer of the consent card, sent on the chat bus to
|
|
// the frontend that runs the turn.
|
|
type HostExecDecision struct {
|
|
ID string `json:"id"`
|
|
Decision string `json:"decision"`
|
|
}
|
|
|
|
// ErrHostExecDecision means the person who sent the answer may not allow
|
|
// commands on this machine.
|
|
var ErrHostExecDecision = errors.New("studio: only an administrator can allow commands on this machine")
|
|
|
|
// ErrHostExecBadDecision means the answer names no card or is not one the card
|
|
// offers. It is a bad request, not a refusal: an administrator who sends it
|
|
// is not denied anything.
|
|
var ErrHostExecBadDecision = errors.New("studio: the answer must name the card and be once or deny")
|
|
|
|
// ErrHostExecExpired means the card has nobody waiting on it: the turn that
|
|
// asked ended (or its frontend died), or a newer card replaced it. The
|
|
// command did not run, and no answer can run it now.
|
|
var ErrHostExecExpired = errors.New("studio: the card expired; the command did not run")
|
|
|
|
// ErrHostExecAnswered means the card was answered already with the other
|
|
// answer. The same answer again is not an error.
|
|
var ErrHostExecAnswered = errors.New("studio: the card was answered already")
|
|
|
|
// hostExecWait is how long a command waits for the card. The card is kept with
|
|
// the chat, so a reload shows it again; the call ends with "no answer" when
|
|
// nobody answers in time.
|
|
var hostExecWait = 5 * time.Minute
|
|
|
|
func (s *Service) hostExecMode() string {
|
|
if s.d.HostExecMode == nil {
|
|
return hostexec.ModeOff
|
|
}
|
|
switch m := s.d.HostExecMode(); m {
|
|
case hostexec.ModeAsk, hostexec.ModeOn:
|
|
return m
|
|
}
|
|
return hostexec.ModeOff
|
|
}
|
|
|
|
func (s *Service) isAdmin(ctx context.Context, user string) bool {
|
|
if s.d.IsAdmin == nil {
|
|
return true // no accounts: the one person is the administrator
|
|
}
|
|
return s.d.IsAdmin(ctx, user)
|
|
}
|
|
|
|
// registerHostBash adds bash to the registry of built-in tools, in the
|
|
// switch group GroupShell. The group's switch hides bash whoever serves it, so
|
|
// the tool is registered also on a server that cannot run commands itself:
|
|
// the Sandbox add-on may serve it. Settings > Tools lists it only where the
|
|
// built-in can run.
|
|
func (s *Service) registerHostBash() error {
|
|
host := s.d.HostExec != nil
|
|
return s.builtins.Register(builtins.Tool{
|
|
ID: commandTool,
|
|
Group: builtins.GroupShell,
|
|
Label: "Commands (bash)",
|
|
Description: hostBashDescription,
|
|
Setting: builtins.SettingHostExec,
|
|
Unlisted: !host,
|
|
Replaceable: true,
|
|
Covers: true,
|
|
Enabled: func() bool { return host && s.hostExecMode() != hostexec.ModeOff },
|
|
Build: s.buildHostBash,
|
|
})
|
|
}
|
|
|
|
// hostBashDescription is the line of Settings > Tools for the built-in bash.
|
|
// It says that an add-on can replace it and names none.
|
|
const hostBashDescription = "Runs commands and code for the chats directly on this machine, with no isolation: commands can read what LocalAI can read " +
|
|
"and reach the internet and your network. An add-on that runs commands in isolation can replace it."
|
|
|
|
// scopedHostBashReason is why a chat whose agent works in one folder has no
|
|
// built-in bash (D10 of the agent folder design): a command on this machine
|
|
// reads what the server can read, and no workspace rule confines it.
|
|
const scopedHostBashReason = "this agent works in one folder, and commands on this machine could read files outside it; it runs commands only in an add-on that isolates them"
|
|
|
|
// buildHostBash gives a chat the built-in bash when the setting allows it.
|
|
// A chat whose actor is fenced (its agent, or its parent's, has a folder)
|
|
// never gets it.
|
|
func (s *Service) buildHostBash(_ context.Context, c builtins.Chat) (session.Tool, bool, string) {
|
|
if s.d.HostExec == nil {
|
|
return session.Tool{}, false, "this server cannot run commands by itself"
|
|
}
|
|
if workspace.HasFence(c.Actor) {
|
|
return session.Tool{}, false, scopedHostBashReason
|
|
}
|
|
if r := s.d.HostExec.Unavailable(); r != "" {
|
|
return session.Tool{}, false, r
|
|
}
|
|
if !s.d.Files.Available() {
|
|
return session.Tool{}, false, "the files are off"
|
|
}
|
|
mode := s.hostExecMode()
|
|
if c.Origin != "" && !c.Agent {
|
|
// An unattended run without an agent never gets a tool that acts at
|
|
// once, as with the add-ons' action tools.
|
|
return session.Tool{}, false, "an unattended run without an agent"
|
|
}
|
|
if mode == hostexec.ModeAsk {
|
|
switch {
|
|
case c.SubAgent || c.Origin != "":
|
|
return session.Tool{}, false, "each command needs a person's yes, and nobody watches this chat"
|
|
case !c.Admin:
|
|
return session.Tool{}, false, "only an administrator can allow commands on this machine"
|
|
}
|
|
}
|
|
tool := s.d.HostExec.Tool(c.Actor)
|
|
a := c.Actor
|
|
run := s.withCommandFiles(a, tool.Run)
|
|
tool.Run = func(ctx context.Context, args json.RawMessage) (string, error) {
|
|
return s.runHostBash(ctx, a, run, args)
|
|
}
|
|
return tool, true, ""
|
|
}
|
|
|
|
// hostExecCardLimit is the longest command the approval card shows in full.
|
|
const hostExecCardLimit = 2000
|
|
|
|
// runHostBash runs one call, after the card when the mode asks for one.
|
|
func (s *Service) runHostBash(ctx context.Context, a workspace.Actor, run func(context.Context, json.RawMessage) (string, error), args json.RawMessage) (string, error) {
|
|
outcome := "ran"
|
|
switch s.hostExecMode() {
|
|
case hostexec.ModeOff:
|
|
return "Commands on this machine were switched off by an administrator. Do not try again.", nil
|
|
case hostexec.ModeAsk:
|
|
if !s.isAdmin(ctx, a.Principal) {
|
|
session.SetToolCard(ctx, session.ToolCard{Kind: HostExecCardKind, ID: "admin"})
|
|
return "Not run: only an administrator can allow commands on this machine. Tell the user to ask one, or give them the command to run.", nil
|
|
}
|
|
var in struct {
|
|
Command string `json:"command"`
|
|
}
|
|
_ = json.Unmarshal(args, &in)
|
|
// The card shows the whole command. A longer one is refused, so the
|
|
// person never approves text they could not read.
|
|
if len(in.Command) > hostExecCardLimit {
|
|
session.SetToolCard(ctx, session.ToolCard{Kind: HostExecCardKind, ID: "denied"})
|
|
return fmt.Sprintf("Not run: the command is longer than %d bytes, too long for the user to read before they allow it. Do not try again as it is; split it into shorter commands, or put the script in a file first.", hostExecCardLimit), nil
|
|
}
|
|
switch d := s.askHostExec(ctx, a, in.Command); d {
|
|
case HostExecOnce:
|
|
outcome = "once"
|
|
case "stopped":
|
|
return "", ctx.Err()
|
|
case "timeout":
|
|
session.SetToolCard(ctx, session.ToolCard{Kind: HostExecCardKind, ID: "timeout"})
|
|
return "Not run: the user did not answer the card that asks to run this command on this machine. Do not try again; ask them.", nil
|
|
default:
|
|
session.SetToolCard(ctx, session.ToolCard{Kind: HostExecCardKind, ID: "denied"})
|
|
return "Not run: the user did not allow this command on this machine. Do not try again; give them the command, or ask what to do.", nil
|
|
}
|
|
}
|
|
out, err := run(ctx, args)
|
|
if err == nil {
|
|
session.SetToolCard(ctx, session.ToolCard{Kind: HostExecCardKind, ID: outcome})
|
|
// What the command wrote is in the user's files now.
|
|
s.bus.Publish(key(a.Principal, a.ChatID), session.Event{Kind: session.KindFiles})
|
|
}
|
|
return out, err
|
|
}
|
|
|
|
// askHostExec shows the card and waits for its answer: once, deny, timeout,
|
|
// or stopped when the turn ends first. The answer is stored with the card
|
|
// before it goes on the chat bus; the call wakes on the message, or finds the
|
|
// answer when it reads the card again every two seconds, as the network card
|
|
// does, so a lost message does not cost the person's click.
|
|
func (s *Service) askHostExec(ctx context.Context, a workspace.Actor, command string) string {
|
|
id := newID()
|
|
k := key(a.Principal, a.ChatID)
|
|
wake, cancel := s.listenConsent(k, func(m ControlMsg) bool { return m.HostExec != nil && m.HostExec.ID == id })
|
|
defer cancel()
|
|
// The card is kept with the chat while it waits, so a reload, or another
|
|
// frontend, shows it again and can answer it. It names the turn that
|
|
// asked, so a card that a dead frontend leaves behind is not taken for a
|
|
// waiting one.
|
|
turn := consentTurn(s.chatTurn(ctx, a.Principal, a.ChatID))
|
|
s.setHostConsent(ctx, a, &session.HostConsent{ID: id, Command: command, Turn: turn, AskedAt: s.now().UTC()})
|
|
defer s.clearHostConsent(context.WithoutCancel(ctx), a.Principal, a.ChatID, id)
|
|
s.bus.Publish(k, session.Event{Kind: session.KindHostExecConsent, IDs: []string{id}, Text: command})
|
|
decision := ""
|
|
if s.waitConsent(ctx, wake, hostExecWait, func() bool {
|
|
rec, err := s.d.Store.GetChat(ctx, a.Principal, a.ChatID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
c := rec.HostConsent
|
|
switch {
|
|
case c == nil || c.ID != id:
|
|
// Cleared by the next turn or the reaper: this turn is over.
|
|
decision = "timeout"
|
|
case c.Decision != "":
|
|
decision = c.Decision
|
|
default:
|
|
return false
|
|
}
|
|
return true
|
|
}) {
|
|
return decision
|
|
}
|
|
if ctx.Err() != nil {
|
|
return "stopped"
|
|
}
|
|
return "timeout"
|
|
}
|
|
|
|
// clearHostConsent clears the card id, and leaves a newer card alone.
|
|
func (s *Service) clearHostConsent(ctx context.Context, p, conv, id string) {
|
|
err := s.d.Store.UpdateHostConsent(ctx, p, conv, func(cur *session.HostConsent) (*session.HostConsent, error) {
|
|
if cur == nil || cur.ID != id {
|
|
return cur, nil
|
|
}
|
|
return nil, nil
|
|
})
|
|
if err != nil {
|
|
slog.Warn("Chats: could not clear the consent card of the chat", "chat", conv, "error", err)
|
|
}
|
|
}
|
|
|
|
// liveHostConsent returns the card of rec when it still has a waiter, by the
|
|
// rule the network cards follow; nil otherwise.
|
|
func (s *Service) liveHostConsent(rec session.ChatRecord, info store.TurnInfo) *session.HostConsent {
|
|
c := rec.HostConsent
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
asked := c.AskedAt
|
|
if asked.IsZero() {
|
|
// A card stored before cards named their turn: trust the turn alone.
|
|
asked = s.now()
|
|
if !info.Running {
|
|
return nil
|
|
}
|
|
}
|
|
if !consentLive(c.Turn, asked, hostExecWait, info, s.now()) {
|
|
return nil
|
|
}
|
|
return c
|
|
}
|
|
|
|
// HostConsent returns the consent card of the chat that waits for an answer,
|
|
// or nil: a card whose turn is over, or that a frontend that died left, is
|
|
// not shown. The chat of another user is session.ErrNotFound.
|
|
func (s *Service) HostConsent(ctx context.Context, user string, rec session.ChatRecord, info store.TurnInfo) *session.HostConsent {
|
|
if rec.Principal != principal(user) {
|
|
return nil
|
|
}
|
|
c := s.liveHostConsent(rec, info)
|
|
if c == nil || c.Decision != "" {
|
|
// An answered card is about to go: the call has its answer.
|
|
return nil
|
|
}
|
|
return c
|
|
}
|
|
|
|
// setHostConsent stores or clears the card that waits. A failure only costs
|
|
// the card after a reload; the live card still works.
|
|
func (s *Service) setHostConsent(ctx context.Context, a workspace.Actor, c *session.HostConsent) {
|
|
if err := s.d.Store.SetHostConsent(ctx, a.Principal, a.ChatID, c); err != nil {
|
|
slog.Warn("Chats: could not keep the consent card with the chat", "chat", a.ChatID, "error", err)
|
|
}
|
|
}
|
|
|
|
// HostExecDecide answers the consent card of a chat. Only an administrator
|
|
// may allow a command; anyone who owns the chat may refuse it. The chat of
|
|
// another user is session.ErrNotFound.
|
|
func (s *Service) HostExecDecide(ctx context.Context, user, conv, id, decision string) error {
|
|
p := principal(user)
|
|
rec, err := s.d.Store.GetChat(ctx, p, conv)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if id == "" || len(id) > 64 {
|
|
return ErrHostExecBadDecision
|
|
}
|
|
switch decision {
|
|
case HostExecDeny:
|
|
case HostExecOnce:
|
|
if !s.isAdmin(ctx, p) {
|
|
return ErrHostExecDecision
|
|
}
|
|
default:
|
|
return ErrHostExecBadDecision
|
|
}
|
|
// An answer to a card nobody waits on reaches no one: say so, rather
|
|
// than a success the command never follows.
|
|
if c := s.liveHostConsent(rec, s.chatTurn(ctx, p, conv)); c == nil || c.ID != id {
|
|
return ErrHostExecExpired
|
|
}
|
|
// The answer goes to the record first, then on the bus: the waiting call
|
|
// finds it on its next read even when the message is lost.
|
|
again := false
|
|
err = s.d.Store.UpdateHostConsent(ctx, p, conv, func(cur *session.HostConsent) (*session.HostConsent, error) {
|
|
switch {
|
|
case cur == nil || cur.ID != id:
|
|
return cur, ErrHostExecExpired
|
|
case cur.Decision == decision:
|
|
again = true // a double click: it is applied, say so again
|
|
return cur, nil
|
|
case cur.Decision != "":
|
|
return cur, ErrHostExecAnswered
|
|
}
|
|
cur.Decision, cur.AnsweredAt = decision, s.now().UTC()
|
|
return cur, nil
|
|
})
|
|
if err != nil || again {
|
|
return err
|
|
}
|
|
s.bus.Control(key(p, conv), ControlMsg{HostExec: &HostExecDecision{ID: id, Decision: decision}})
|
|
return nil
|
|
}
|
|
|
|
// Builtins returns the registry of the built-in tools (bash, web_search,
|
|
// web_fetch), for Settings > Tools and for other features to register theirs.
|
|
func (s *Service) Builtins() *builtins.Registry { return s.builtins }
|
|
|
|
// builtinsFor builds the built-in tools of a chat.
|
|
func (s *Service) builtinsFor(ctx context.Context, a workspace.Actor, rec session.ChatRecord, subAgent bool) []builtins.Built {
|
|
off, on := s.switchesOf(ctx, rec)
|
|
chat := builtins.Chat{Actor: a, Origin: rec.Origin, OriginID: rec.OriginID, Agent: rec.AgentID != "", AgentID: rec.AgentID, Model: rec.Model, SubAgent: subAgent,
|
|
Admin: s.isAdmin(ctx, a.Principal), GroupsOff: off, GroupsOn: on, Grants: s.chatGrants(ctx, rec), GoalActive: goalActive(rec) && !subAgent}
|
|
if rec.Origin == session.OriginConnector {
|
|
// A connector chat acts for the sender of the turn, not for its owner:
|
|
// the tools that need a right ask what that sender holds.
|
|
chat.Allow, chat.Audit = s.turnAllow(a.ChatID)
|
|
}
|
|
return s.builtins.Build(ctx, chat)
|
|
}
|
|
|
|
// applyBuiltins adds the built-in tools that no add-on serves. When the
|
|
// built-in bash is added, the stand-in of an add-on that runs commands but
|
|
// cannot now is left out: bash serves instead, and the Tools menu says so.
|
|
func (s *Service) applyBuiltins(tools []session.Tool, built []builtins.Built) []session.Tool {
|
|
var add []session.Tool
|
|
for _, b := range built {
|
|
if b.OK {
|
|
add = append(add, b.Tool)
|
|
}
|
|
}
|
|
tools = withBuiltins(tools, add...)
|
|
if providerOf(tools, "bash") != BuiltinProvider {
|
|
return tools
|
|
}
|
|
out := tools[:0:0]
|
|
for _, t := range tools {
|
|
if standInRunner([]session.Tool{t}) != "" {
|
|
continue
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// runsCommands reports whether a tool list has a tool that runs commands: an
|
|
// add-on's or the built-in bash.
|
|
func (s *Service) runsCommands(tools []session.Tool) bool {
|
|
if addonRunner(tools) != "" {
|
|
return true
|
|
}
|
|
return providerOf(tools, "bash") == BuiltinProvider
|
|
}
|
|
|
|
// SharedRoots lists the shared folders a user may use, for the scratch
|
|
// folder of the built-in bash.
|
|
func (s *Service) SharedRoots(ctx context.Context, a workspace.Actor) ([]session.SharedRoot, error) {
|
|
return s.shared.Roots(ctx, a)
|
|
}
|