Files
LocalAI/core/services/studio/agentaccess.go
T
Ettore Di Giacinto db596e1696 feat(studio): accept a list of agent folders in the API and the agent file
POST and PUT on /api/chats/agents take folders, a list of path and mode
rows, up to eight. The old folder field still works and is read as a
list with one entry. Responses carry folders, the effective mode of each
row and a notice with the count of shared folders a chat does not mount.

A folder inside another folder of the same agent takes the mode of the
outer one, because the file store gives a node the highest mode among
the roots above it and cannot hold a read only folder inside a write
folder. The editor data shows that mode.

An agent with one folder still exports as version 2. An agent with
several exports as version 3 with folders. A server that reads version 2
refuses version 3. Import keeps a folder that is not found as a row
that is not chosen.

Assisted-by: Claude:claude-sonnet-5-5 go gofmt
2026-11-03 14:02:02 +00:00

449 lines
16 KiB
Go

package studio
import (
"context"
"errors"
"fmt"
"sort"
"github.com/mudler/LocalAI/core/services/studio/agents"
"github.com/mudler/LocalAI/core/services/studio/files"
"github.com/mudler/LocalAI/core/services/studio/session"
"github.com/mudler/LocalAI/core/services/studio/sharedfolders"
"github.com/mudler/LocalAI/core/services/studio/workspace"
)
// The access of a chat is resolved from the stores at every turn start: the
// chat record, its saved agent (or, for a sub-agent, the parent chat's agent)
// and the agent's folder node. Nothing is kept between turns, so an edit of
// the agent applies from the next turn on every frontend and chat worker
// (stateless per request, .agents/distributed-state.md).
//
// A chat whose agent has a folder gets a fenced actor: the store refuses
// every node outside the agent's folder, the chat's own folder, the shared
// folders and the skill layers, whoever calls it with that actor. A chat
// whose folder is missing or not chosen does not start
// (ErrAgentFolderMissing); it never falls back to the whole home.
// ErrAgentFolderMissing means the chat's agent has a folder that is deleted,
// that the user can no longer read, or that is not chosen yet, or that the
// chat's agent had a folder and is gone. The chat does not start.
var ErrAgentFolderMissing = errors.New("studio: the folder of this agent is missing")
// ErrAgentFolderLimit means the complete scoped access exceeds the fence budget.
// The caller may retry after the available shared folders change.
var ErrAgentFolderLimit = errors.New("studio: the file access of this agent exceeds the folder limit")
// scopedFolder is one folder of a saved agent as it is now, at the mode the
// agent may use it: the mode it was given, capped by every folder of the same
// agent that holds it.
type scopedFolder struct {
node int64
mode workspace.Mode
path string // workspace path
}
// folderScope is the folders of a saved agent as they are now, in the order
// the agent lists them. The first is the one the file tools call "home".
type folderScope struct {
folders []scopedFolder
}
// primary is the first folder of the scope.
func (sc *folderScope) primary() scopedFolder { return sc.folders[0] }
// extraPaths are the workspace paths of the folders after the first.
func (sc *folderScope) extraPaths() []string {
out := make([]string, 0, len(sc.folders)-1)
for _, f := range sc.folders[1:] {
out = append(out, f.path)
}
return out
}
// roots are the fence roots of the scope.
func (sc *folderScope) roots() []workspace.FenceRoot {
out := make([]workspace.FenceRoot, 0, len(sc.folders))
for _, f := range sc.folders {
out = append(out, workspace.FenceRoot{Node: f.node, Mode: f.mode})
}
return out
}
// userChatActor is the user's actor in a chat, without a fence: the actor of
// a chat whose agent has no folder, and the base that chatAccess and
// agentReader fence. A person's own action on a chat (a delete) uses it too.
// Every other actor of a chat comes from chatAccess.
func userChatActor(p, conv string) workspace.Actor {
a := files.UserActor(p)
a.ChatID = conv
return a
}
// ChatAccess returns the access of a chat for this turn, for code outside the
// package (command runners, add-on host operations). The chat of another user
// is session.ErrNotFound.
func (s *Service) ChatAccess(ctx context.Context, user, conv string) (session.Access, error) {
rec, err := s.d.Store.GetChat(ctx, principal(user), conv)
if err != nil {
return session.Access{}, err
}
return s.chatAccess(ctx, rec)
}
// resolveAccess is the session's Resolve hook: the access of the chat of a.
func (s *Service) resolveAccess(ctx context.Context, a workspace.Actor) (session.Access, error) {
return s.ChatAccess(ctx, a.Principal, a.ChatID)
}
// chatAccess is the access of a chat for this turn: its actor, fenced when the
// chat's agent (or, for a sub-agent, the parent's agent) has a folder, and the
// view of its folders. It makes the chat's folder before it fences, because a
// fenced actor cannot make a folder outside its fence. ErrAgentFolderMissing
// when the folder is missing or not chosen.
func (s *Service) chatAccess(ctx context.Context, rec session.ChatRecord) (session.Access, error) {
base := userChatActor(rec.Principal, rec.ID)
scopes, holder, err := s.chatScopes(ctx, rec, true)
if err != nil {
return session.Access{}, err
}
if len(scopes) == 0 {
return s.mgr.UserAccess(ctx, base)
}
readOnly, _, err := s.scopedRoots(ctx, base, scopes)
if err != nil {
return session.Access{}, err
}
owner := base
owner.ChatID = ""
chatDir, err := s.mgr.EnsureChatFolder(ctx, owner, holder)
if err != nil {
return session.Access{}, fmt.Errorf("studio: make the chat's folder: %w", err)
}
actor, err := fenceScopes(base, scopes, chatDir.ID, readOnly)
if err != nil {
return session.Access{}, err
}
// The innermost folder is the one the chat works in: a sub-agent's own
// folder inside its parent's. The store caps the mode by every layer.
// The view is the session's one view builder: the folder at home, the
// chat folder at chat, the shared folders read only.
work := scopes[len(scopes)-1]
x, err := s.mgr.ScopedAccessFolders(ctx, actor, work.primary().path, work.extraPaths(), chatDir.Path)
if errors.Is(err, session.ErrNoHome) || errors.Is(err, workspace.ErrNotFound) {
// The folder went away since it was checked, or a sub-agent's folder
// is outside its parent's.
return session.Access{}, ErrAgentFolderMissing
}
if err != nil {
return session.Access{}, err
}
return x, nil
}
// chatActorFor is the actor of a chat for the code that only lists what a
// chat could use (the tools panel): the same fence as chatAccess, without
// making the chat's folder. A chat whose folder is missing gets an actor that
// reaches nothing.
func (s *Service) chatActorFor(ctx context.Context, rec session.ChatRecord) workspace.Actor {
base := userChatActor(rec.Principal, rec.ID)
scopes, holder, err := s.chatScopes(ctx, rec, false)
if err != nil {
return base.Fenced()
}
if len(scopes) == 0 {
return base
}
readOnly, _, err := s.scopedRoots(ctx, base, scopes)
if err != nil {
return base.Fenced()
}
var chatNode int64
if hrec, herr := s.d.Store.GetChat(ctx, rec.Principal, holder); herr == nil && hrec.Folder != "" {
owner := base
owner.ChatID = ""
if n, serr := s.d.Files.Store().Stat(ctx, owner, session.ChatsPath(rec.Principal)+"/"+hrec.Folder); serr == nil {
chatNode = n.ID
}
}
actor, err := fenceScopes(base, scopes, chatNode, readOnly)
if err != nil {
return base.Fenced()
}
return actor
}
// fenceScopes fences base with one layer per scope. Each layer holds the
// scope's folder at its mode, the chat's folder (when chatNode is not 0) read
// and write, and the read-only roots; the layers intersect, so a sub-agent
// whose own agent has a folder reaches only what both folders allow.
func fenceScopes(base workspace.Actor, scopes []*folderScope, chatNode int64, readOnly []workspace.FenceRoot) (workspace.Actor, error) {
// Reserve the chat root even before its folder exists. Check every
// layer before building any actor; roots must never be truncated.
for _, sc := range scopes {
if 1+len(sc.folders)+len(readOnly) > workspace.MaxFenceRoots {
return workspace.Actor{}, ErrAgentFolderLimit
}
}
a := base
for _, sc := range scopes {
roots := sc.roots()
if chatNode > 0 {
roots = append(roots, workspace.FenceRoot{Node: chatNode, Mode: workspace.ModeWrite})
}
roots = append(roots, readOnly...)
a = a.Fenced(roots...)
}
return a, nil
}
// scopedRoots resolves the complete optional read set and validates the budget
// without making a chat folder. Creation and every turn use the same check.
// notMounted counts the shared folders left out to fit the budget.
func (s *Service) scopedRoots(ctx context.Context, base workspace.Actor, scopes []*folderScope) ([]workspace.FenceRoot, int, error) {
var mandatory []string
reserve := 0
for _, sc := range scopes {
reserve = max(reserve, len(sc.folders))
for _, f := range sc.folders {
mandatory = append(mandatory, f.path)
}
}
roots, dropped, err := s.readOnlyRoots(ctx, base, reserve, mandatory)
if err != nil {
return nil, 0, err
}
if _, err := fenceScopes(base, scopes, 0, roots); err != nil {
return nil, 0, err
}
return roots, dropped, nil
}
// readOnlyRoots lists the folders a scoped chat may read besides its own: the
// skill layers and the folders shared with the user, all read only (the
// shared folders' grants may allow more; a scoped chat never writes there).
// A folder that is missing or unreadable is left out. Enumeration failures
// refuse access rather than silently omitting shared folders.
//
// A fence layer holds at most workspace.MaxFenceRoots roots: the chat folder,
// reserve folders of the agent, the skill layers and the rest for shared
// folders. The skill layers and the agent's folders are always kept. A shared
// folder inside a root of the layer is left out (the root already reaches it).
// Of the others the ones with the lowest node IDs are kept until the budget is
// full, and dropped counts the rest. Dropping a root only removes access.
// mandatory are the workspace paths of the agent's folders.
func (s *Service) readOnlyRoots(ctx context.Context, base workspace.Actor, reserve int, mandatory []string) ([]workspace.FenceRoot, int, error) {
owner := base
owner.ChatID = ""
ws := s.d.Files.Store()
var out []workspace.FenceRoot
var held []string // paths of the roots already in the layer
held = append(held, mandatory...)
stat := func(p string) (workspace.Node, bool) {
n, err := ws.Stat(ctx, owner, p)
return n, err == nil && n.Kind == workspace.KindDir
}
for _, p := range []string{files.SharedSkillsPath, files.BuiltinSkillsPath, files.AddonSkillsPath} {
if n, ok := stat(p); ok {
out = append(out, workspace.FenceRoot{Node: n.ID, Mode: workspace.ModeRead})
held = append(held, n.Path)
}
}
if s.shared == nil {
return out, 0, nil
}
shared, err := s.shared.Roots(ctx, owner)
if err != nil {
return nil, 0, fmt.Errorf("studio: list shared folders for scoped access: %w", err)
}
var cand []workspace.Node
for _, r := range shared {
if n, ok := stat(r.Path); ok {
cand = append(cand, n)
}
}
sort.Slice(cand, func(i, j int) bool { return cand[i].ID < cand[j].ID })
room := workspace.MaxFenceRoots - 1 - reserve - len(out)
dropped := 0
for _, n := range cand {
if insideAny(n.Path, held) {
continue
}
if room <= 0 {
dropped++
continue
}
room--
out = append(out, workspace.FenceRoot{Node: n.ID, Mode: workspace.ModeRead})
held = append(held, n.Path)
}
return out, dropped, nil
}
// insideAny says whether p is one of roots or inside one.
func insideAny(p string, roots []string) bool {
for _, r := range roots {
if workspace.Within(p, r) {
return true
}
}
return false
}
// chatScopes returns the agent folders that apply to a chat, outermost first,
// and the chat whose folder is the chat's own folder. A chat applies its own
// agent's folder; a sub-agent applies its parent's agent's folder, and its
// own agent's folder inside it. No scope means the chat works on the whole
// home. With record, the chat's ScopedAgent flag is set or cleared to match
// what its agent has now.
func (s *Service) chatScopes(ctx context.Context, rec session.ChatRecord, record bool) ([]*folderScope, string, error) {
holder := rec.ID
var scopes []*folderScope
if rec.Kind == session.KindSubAgent && rec.ParentID != "" {
holder = rec.ParentID
parent, err := s.d.Store.GetChat(ctx, rec.Principal, rec.ParentID)
switch {
case errors.Is(err, session.ErrNotFound):
// The parent is gone: nothing is known of its folder.
if rec.ScopedAgent {
return nil, "", ErrAgentFolderMissing
}
holder = rec.ID
case err != nil:
return nil, "", err
default:
sc, err := s.agentFolderScope(ctx, parent, false)
if err != nil {
return nil, "", err
}
if sc != nil {
scopes = append(scopes, sc)
}
}
}
// A sub-agent's flag follows both folders, not only its own agent's: a
// child of a scoped chat stays refused, never given the whole home, when
// its parent's folder can no longer be found.
sub := rec.Kind == session.KindSubAgent && rec.ParentID != ""
sc, err := s.agentFolderScope(ctx, rec, record && !sub)
if err != nil {
return nil, "", err
}
if sc != nil {
scopes = append(scopes, sc)
}
if record && sub {
if err := s.markScoped(ctx, rec, len(scopes) > 0); err != nil {
return nil, "", err
}
}
return scopes, holder, nil
}
// markScoped sets the chat's ScopedAgent flag to match what its folders are
// now. Failing to set it is an error only when the chat is scoped: without the
// flag a later delete of the agent would give the chat the whole home.
func (s *Service) markScoped(ctx context.Context, rec session.ChatRecord, scoped bool) error {
if rec.ScopedAgent == scoped {
return nil
}
err := s.d.Store.SetScopedAgent(ctx, rec.Principal, rec.ID, scoped)
switch {
case err != nil && scoped:
return fmt.Errorf("studio: mark the chat as scoped: %w", err)
case err != nil:
s.log.Warn("studio: could not clear the scoped mark of a chat", "chat", rec.ID, "error", err)
}
return nil
}
// agentFolderScope returns the folder of the saved agent of rec, nil for a
// chat without an agent or whose agent has no folder.
func (s *Service) agentFolderScope(ctx context.Context, rec session.ChatRecord, record bool) (*folderScope, error) {
if rec.AgentID == "" {
return nil, nil
}
var ag agents.Agent
err := agents.ErrNotFound
if s.d.Agents != nil {
ag, err = s.d.Agents.Get(ctx, rec.Principal, rec.AgentID)
}
switch {
case errors.Is(err, agents.ErrNotFound):
// A deleted agent leaves no folder to read. A chat that knew its
// agent had one is refused; one that did not keeps today's rule.
if rec.ScopedAgent {
return nil, ErrAgentFolderMissing
}
return nil, nil
case err != nil:
// Without the agent nothing says whether it has a folder.
return nil, fmt.Errorf("studio: read the chat's agent: %w", err)
}
if record {
if err := s.markScoped(ctx, rec, ag.HasFolder()); err != nil {
return nil, err
}
}
if !ag.HasFolder() {
return nil, nil
}
return s.liveFolder(ctx, rec.Principal, ag)
}
// liveFolder returns the folders of an agent that has some, or
// ErrAgentFolderMissing when one is not chosen, deleted or unreadable. The
// error names the row, so the person knows which folder to fix. The limits of
// the list are checked again here: a store may hold an older shape.
func (s *Service) liveFolder(ctx context.Context, p string, ag agents.Agent) (*folderScope, error) {
list := ag.FolderList()
if err := agents.ValidateFolders(list); err != nil {
return nil, err
}
sc := &folderScope{}
for i, f := range list {
if f.Node <= 0 {
return nil, fmt.Errorf("%w: folder %d is not chosen", ErrAgentFolderMissing, i+1)
}
path, ok := s.FolderResolver().PathOf(ctx, p, f.Node)
if !ok {
return nil, fmt.Errorf("%w: folder %d was deleted or can no longer be read", ErrAgentFolderMissing, i+1)
}
mode := workspace.ModeRead
// A shared folder is read only, whatever the list says.
if f.Mode == agents.FolderWrite && !isSharedPath(path) {
mode = workspace.ModeWrite
}
sc.folders = append(sc.folders, scopedFolder{node: f.Node, mode: mode, path: path})
}
capModes(sc.folders)
return sc, nil
}
// isSharedPath says whether a workspace path is in the shared folders.
func isSharedPath(p string) bool { return workspace.Within(p, sharedfolders.Root) }
// capModes applies the rule of nested folders: where one folder holds
// another, the mode of the outer folder is the mode of the inner one. The
// store's fence gives a node the highest mode among the roots above it, so a
// read only folder inside a write folder cannot be enforced; the inner folder
// therefore takes the outer mode, in both directions, and the editor shows it.
func capModes(fs []scopedFolder) {
eff := make([]workspace.Mode, len(fs))
for i, f := range fs {
eff[i] = f.mode
outer := -1
for j, o := range fs {
if i != j && f.path != o.path && workspace.Within(f.path, o.path) && (outer < 0 || len(o.path) < len(fs[outer].path)) {
outer = j
}
}
if outer >= 0 {
eff[i] = fs[outer].mode
}
}
for i := range fs {
fs[i].mode = eff[i]
}
}