diff --git a/core/services/studio/session/access.go b/core/services/studio/session/access.go index 7516195cd0..9b6cb03b6b 100644 --- a/core/services/studio/session/access.go +++ b/core/services/studio/session/access.go @@ -57,7 +57,7 @@ func sameView(a, b workspace.View) bool { // is the one place a chat's view is built: UserAccess (the home) and // ScopedAccess (the agent's folder and the chat folder) both call it, so an // unscoped and a scoped chat get the same layout rules. -func (m *Manager) buildView(ctx context.Context, a workspace.Actor, primary, chat string) (workspace.View, error) { +func (m *Manager) buildView(ctx context.Context, a workspace.Actor, primary string, extra []string, chat string) (workspace.View, error) { if m.o.Workspace == nil { return workspace.View{Actor: a}, nil } @@ -68,7 +68,7 @@ func (m *Manager) buildView(ctx context.Context, a workspace.Actor, primary, cha return workspace.View{}, fmt.Errorf("session: list shared folders: %w", err) } } - return BuildView(ctx, m.o.Workspace, a, primary, chat, shared, m.log) + return BuildViewFolders(ctx, m.o.Workspace, a, primary, extra, chat, shared, m.log) } // UserAccess is the access of a chat that works on the whole home: the @@ -78,7 +78,7 @@ func (m *Manager) UserAccess(ctx context.Context, a workspace.Actor) (Access, er if _, fenced := a.Fence(); fenced { return Access{}, fmt.Errorf("%w: a fenced actor has no view of the home", ErrInvalid) } - v, err := m.buildView(ctx, a, HomePath(a.Principal), "") + v, err := m.buildView(ctx, a, HomePath(a.Principal), nil, "") if err != nil { return Access{}, err } @@ -93,6 +93,13 @@ func (m *Manager) UserAccess(ctx context.Context, a workspace.Actor) (Access, er // primary) or an error that wraps workspace.ErrNotFound (the chat folder), // never a view of the home. func (m *Manager) ScopedAccess(ctx context.Context, a workspace.Actor, primary, chat string) (Access, error) { + return m.ScopedAccessFolders(ctx, a, primary, nil, chat) +} + +// ScopedAccessFolders is ScopedAccess for an agent with several folders: +// primary is the first one, at "home", and extra are the others, at +// "folder-" (see FolderMountNames). +func (m *Manager) ScopedAccessFolders(ctx context.Context, a workspace.Actor, primary string, extra []string, chat string) (Access, error) { if !workspace.HasFence(a) { return Access{}, fmt.Errorf("%w: a scoped access needs a fenced actor", ErrInvalid) } @@ -102,7 +109,7 @@ func (m *Manager) ScopedAccess(ctx context.Context, a workspace.Actor, primary, if m.o.Workspace == nil { return Access{}, ErrNoWorkspace } - v, err := m.buildView(ctx, a, primary, chat) + v, err := m.buildView(ctx, a, primary, extra, chat) if err != nil { return Access{}, err } diff --git a/core/services/studio/session/addonaccess.go b/core/services/studio/session/addonaccess.go index 96d9d89ec5..1f63dbafdf 100644 --- a/core/services/studio/session/addonaccess.go +++ b/core/services/studio/session/addonaccess.go @@ -22,7 +22,7 @@ func (m *Manager) PersonAccess(ctx context.Context, a workspace.Actor) (Access, case workspace.HasFence(a): return Access{}, fmt.Errorf("%w: a fenced actor has no view of the home", ErrInvalid) } - v, err := m.buildView(ctx, a, HomePath(a.Principal), "") + v, err := m.buildView(ctx, a, HomePath(a.Principal), nil, "") if err != nil { return Access{}, err } diff --git a/core/services/studio/session/layout.go b/core/services/studio/session/layout.go index 41fee415c4..d7b6f0ecc4 100644 --- a/core/services/studio/session/layout.go +++ b/core/services/studio/session/layout.go @@ -34,6 +34,9 @@ const ( // GuestSharedPrefix starts the mount name of a shared folder: // /workspace/shared-. A mount name is one path segment. GuestSharedPrefix = "shared-" + // GuestFolderPrefix starts the mount name of an agent's folder after the + // first: /workspace/folder-. The first folder is "home". + GuestFolderPrefix = "folder-" // DefaultTitle is the title of a new chat. DefaultTitle = "New chat" // MaxTitleRunes is the longest title of a chat, in characters. @@ -188,6 +191,43 @@ type SharedRoot struct { // folder that cannot be mounted is an error too, since the resolver makes it // before it fences. func BuildView(ctx context.Context, st workspace.Store, a workspace.Actor, primary string, chat string, shared []SharedRoot, log *slog.Logger) (workspace.View, error) { + return BuildViewFolders(ctx, st, a, primary, nil, chat, shared, log) +} + +// reservedMountNames are the names no folder may take: the home, the chat +// folder and the skill layers. +func reservedMountNames() map[string]bool { + used := map[string]bool{workspace.FoldName(GuestHome): true, workspace.FoldName(GuestChat): true} + for _, l := range SkillLayers { + used[workspace.FoldName(l.At)] = true + } + return used +} + +// FolderMountNames returns the mount name of each further folder of an agent, +// in order: "folder-", with " 2", " 3" and so on when two folders have +// the same name. The same call gives the same names, so the code that builds +// the view and the code that tells the model about it agree. +func FolderMountNames(paths []string) []string { + used := reservedMountNames() + out := make([]string, 0, len(paths)) + for _, p := range paths { + base := strings.TrimRight(fitBytes(GuestFolderPrefix+path.Base(p), maxNameBytes-len(" 999")), " ") + at, err := uniqueName(base, func(n string) (bool, error) { return used[workspace.FoldName(n)], nil }) + if err != nil { + at = fmt.Sprintf("%s%d", GuestFolderPrefix, len(out)+1) + } + used[workspace.FoldName(at)] = true + out = append(out, at) + } + return out +} + +// BuildViewFolders is BuildView for an agent with several folders: primary is +// the first and extra are the others, mounted at "folder-". The store +// caps each mount at the fence's mode. An extra folder the store refuses is +// skipped and logged. +func BuildViewFolders(ctx context.Context, st workspace.Store, a workspace.Actor, primary string, extra []string, chat string, shared []SharedRoot, log *slog.Logger) (workspace.View, error) { home := HomePath(a.Principal) reqs := []workspace.MountRequest{{Path: primary, At: GuestHome, Mode: workspace.ModeWrite}} v, err := st.View(ctx, a, reqs) @@ -208,9 +248,18 @@ func BuildView(ctx context.Context, st workspace.Store, a workspace.Actor, prima // The names of the skill layers and of the chat folder are reserved: the // file tools read them there, so a shared folder named skills gets // "shared-skills 2". - used := map[string]bool{workspace.FoldName(GuestHome): true, workspace.FoldName(GuestChat): true} - for _, l := range SkillLayers { - used[workspace.FoldName(l.At)] = true + used := reservedMountNames() + for i, at := range FolderMountNames(extra) { + ev, err := st.View(ctx, a, []workspace.MountRequest{{Path: extra[i], At: at, Mode: workspace.ModeWrite}}) + if err != nil { + if missingFolder(err) { + log.Warn("session: skipping a folder of the agent", "path", extra[i], "error", err) + continue + } + return workspace.View{}, err + } + used[workspace.FoldName(at)] = true + v.Mounts = append(v.Mounts, ev.Mounts...) } for _, s := range shared { p := path.Clean("/" + strings.Trim(s.Path, "/")) @@ -236,6 +285,9 @@ func BuildView(ctx context.Context, st workspace.Store, a workspace.Actor, prima sv, err := st.View(ctx, a, []workspace.MountRequest{{Path: p, At: at, Mode: mode}}) if err != nil { if missingFolder(err) { + if fenced { + v.NotMounted++ + } log.Warn("session: skipping a shared folder", "path", p, "error", err) continue } @@ -312,12 +364,17 @@ func scopedLayoutPrompt(v workspace.View) string { fmt.Fprintf(&b, "- %s/%s: the user's home folder (%s).\n", GuestRoot, m.At, access) case m.At == GuestHome: fmt.Fprintf(&b, "- %s/%s: the folder %q of the user (%s). You cannot see the user's other files.\n", GuestRoot, m.At, path.Base(m.Path), access) + case strings.HasPrefix(m.At, GuestFolderPrefix): + fmt.Fprintf(&b, "- %s/%s: the folder %q of the user (%s). You cannot see the user's other files.\n", GuestRoot, m.At, path.Base(m.Path), access) case m.At == GuestChat: fmt.Fprintf(&b, "- %s/%s: this chat's own folder (%s). Files the user attaches are here; save working files in %s/%s/%s/.\n", GuestRoot, m.At, access, GuestRoot, m.At, workfiles.Dir) default: fmt.Fprintf(&b, "- %s/%s: a folder shared with the user (%s).\n", GuestRoot, m.At, access) } } + if v.NotMounted > 0 { + fmt.Fprintf(&b, "%d shared folders are not mounted: they did not fit the access limit of this chat.\n", v.NotMounted) + } b.WriteString("There is nothing else under " + GuestRoot + ". Every change is saved, and the user can undo it.") return b.String() } diff --git a/core/services/studio/session/layout_test.go b/core/services/studio/session/layout_test.go index a0b510b98f..325ff42fc2 100644 --- a/core/services/studio/session/layout_test.go +++ b/core/services/studio/session/layout_test.go @@ -183,3 +183,62 @@ var _ = Describe("the layout of a chat's view", func() { Expect(strings.Count(p, "\n- ")).To(Equal(3)) }) }) + +var _ = Describe("the view of an agent with several folders", func() { + It("mounts the first at home and the others by name, each capped by the fence", func() { + r := newLayoutRig() + other := session.HomePath("alice") + "/Other" + on, err := r.ws.Stat(context.Background(), r.admin, other) + Expect(err).NotTo(HaveOccurred()) + a := workspace.Actor{Principal: "alice", ChatID: "c1"}.Fenced( + workspace.FenceRoot{Node: r.acme.ID, Mode: workspace.ModeWrite}, + workspace.FenceRoot{Node: on.ID, Mode: workspace.ModeRead}, + workspace.FenceRoot{Node: r.chat.ID, Mode: workspace.ModeWrite}, + ) + v, err := session.BuildViewFolders(context.Background(), r.ws, a, r.acme.Path, []string{other}, r.chat.Path, nil, quiet) + Expect(err).NotTo(HaveOccurred()) + home, ok := mountAt(v, session.GuestHome) + Expect(ok).To(BeTrue()) + Expect(home.Path).To(Equal(r.acme.Path)) + Expect(home.Mode).To(Equal(workspace.ModeWrite)) + o, ok := mountAt(v, "folder-Other") + Expect(ok).To(BeTrue(), "%+v", v.Mounts) + Expect(o.Mode).To(Equal(workspace.ModeRead), "the fence caps the mount") + Expect(session.LayoutPrompt(v, session.PromptScope(v))).To(ContainSubstring("/workspace/folder-Other")) + }) + + It("skips a folder outside the fence instead of widening it", func() { + r := newLayoutRig() + other := session.HomePath("alice") + "/Other" + v, err := session.BuildViewFolders(context.Background(), r.ws, r.scoped(workspace.ModeWrite), r.acme.Path, []string{other}, r.chat.Path, nil, quiet) + Expect(err).NotTo(HaveOccurred()) + _, ok := mountAt(v, "folder-Other") + Expect(ok).To(BeFalse(), "the folder is not a root of the fence") + }) + + It("names two folders with the same name apart and never takes a reserved name", func() { + names := session.FolderMountNames([]string{"/users/a/X/Docs", "/users/a/Y/Docs", "/users/a/Z/Docs"}) + Expect(names).To(Equal([]string{"folder-Docs", "folder-Docs 2", "folder-Docs 3"})) + }) + + It("refuses a path that climbs out of a mounted folder", func() { + r := newLayoutRig() + other := session.HomePath("alice") + "/Other" + on, _ := r.ws.Stat(context.Background(), r.admin, other) + a := workspace.Actor{Principal: "alice", ChatID: "c1"}.Fenced( + workspace.FenceRoot{Node: r.acme.ID, Mode: workspace.ModeWrite}, + workspace.FenceRoot{Node: on.ID, Mode: workspace.ModeRead}, + workspace.FenceRoot{Node: r.chat.ID, Mode: workspace.ModeWrite}) + v, err := session.BuildViewFolders(context.Background(), r.ws, a, r.acme.Path, []string{other}, r.chat.Path, nil, quiet) + Expect(err).NotTo(HaveOccurred()) + pl := session.NewPlace(v) + for _, p := range []string{"/workspace/folder-Other/../home/x", "/workspace/folder-Other/../../alice/secret", "folder-Other/.."} { + _, err := pl.Resolve(p) + Expect(err).To(HaveOccurred(), p) + } + t, err := pl.Resolve("/workspace/folder-Other/notes.md") + Expect(err).NotTo(HaveOccurred()) + Expect(t.Write).To(BeFalse()) + Expect(t.Abs).To(Equal(other + "/notes.md")) + }) +}) diff --git a/core/services/studio/workspace/types.go b/core/services/studio/workspace/types.go index a53548d4c1..3ef06a507a 100644 --- a/core/services/studio/workspace/types.go +++ b/core/services/studio/workspace/types.go @@ -292,6 +292,10 @@ type Mount struct { type View struct { Actor Actor Mounts []Mount + // NotMounted counts the shared folders a fenced view leaves out because + // the fence does not reach them (the budget of a fence layer is limited). + // It is for the prompt and the editor; it grants nothing. + NotMounted int } // HistoryQuery selects change sets for History.