mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
The previous commit was made on a stale base and so reverted work that landed on master before it. This commit puts every file back to the state of its parent's parent. The permission enforcement of the previous commit stays. Assisted-by: Claude:claude-sonnet-5-5 go
449 lines
16 KiB
Go
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]
|
|
}
|
|
}
|