diff --git a/cmd/ollie-9p-mount/main.go b/cmd/ollie-9p-mount/main.go index 665cb68..d95f2b6 100644 --- a/cmd/ollie-9p-mount/main.go +++ b/cmd/ollie-9p-mount/main.go @@ -23,6 +23,7 @@ import ( "9fans.net/go/plan9/client" "github.com/hanwen/go-fuse/v2/fs" "github.com/hanwen/go-fuse/v2/fuse" + perms "olliesrv/fs" ) var ( @@ -54,7 +55,7 @@ func main() { log.Fatalf("dial: %v", err) } - os.MkdirAll(mnt, 0755) + os.MkdirAll(mnt, perms.PermMkdir) root := &p9Dir{fsys: fsys, path: ""} server, err := fs.Mount(mnt, root, &fs.Options{ MountOptions: fuse.MountOptions{ diff --git a/cmd/ollie-9p-mount/node.go b/cmd/ollie-9p-mount/node.go index 1ee9355..fb58f62 100644 --- a/cmd/ollie-9p-mount/node.go +++ b/cmd/ollie-9p-mount/node.go @@ -2,6 +2,8 @@ package main import ( "context" + "os/user" + "strconv" "sync" "syscall" @@ -29,6 +31,11 @@ type p9File struct { var _ = (fs.NodeGetattrer)((*p9Dir)(nil)) var _ = (fs.NodeReaddirer)((*p9Dir)(nil)) var _ = (fs.NodeLookuper)((*p9Dir)(nil)) +var _ = (fs.NodeCreater)((*p9Dir)(nil)) +var _ = (fs.NodeUnlinker)((*p9Dir)(nil)) +var _ = (fs.NodeMkdirer)((*p9Dir)(nil)) +var _ = (fs.NodeRmdirer)((*p9Dir)(nil)) +var _ = (fs.NodeRenamer)((*p9Dir)(nil)) var _ = (fs.NodeGetattrer)((*p9File)(nil)) var _ = (fs.NodeOpener)((*p9File)(nil)) @@ -46,7 +53,17 @@ func p9path(base, name string) string { // --- p9Dir --- func (d *p9Dir) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno { - out.Mode = 0555 | syscall.S_IFDIR + path := d.path + if path == "" { + path = "/" + } + dir, err := d.fsys.Stat(path) + if err != nil { + return syscall.ENOENT + } + out.Mode = uint32(dir.Mode)&0777 | syscall.S_IFDIR + out.Uid = lookupUid(dir.Uid) + out.Gid = lookupGid(dir.Gid) return 0 } @@ -81,6 +98,8 @@ func (d *p9Dir) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*f if err != nil { return nil, syscall.ENOENT } + out.Uid = lookupUid(dir.Uid) + out.Gid = lookupGid(dir.Gid) if dir.Mode&plan9.DMDIR != 0 { out.Mode = uint32(dir.Mode)&0777 | syscall.S_IFDIR node := &p9Dir{fsys: d.fsys, path: child} @@ -92,6 +111,58 @@ func (d *p9Dir) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*f return d.NewInode(ctx, node, fs.StableAttr{}), 0 } +func (d *p9Dir) Create(ctx context.Context, name string, flags uint32, mode uint32, out *fuse.EntryOut) (*fs.Inode, fs.FileHandle, uint32, syscall.Errno) { + child := p9path(d.path, name) + fid, err := d.fsys.Create(child, plan9.OWRITE, plan9.Perm(mode&0777)) + if err != nil { + return nil, nil, 0, syscall.EIO + } + fid.Close() + out.Mode = mode & 0777 + node := &p9File{fsys: d.fsys, path: child} + inode := d.NewInode(ctx, node, fs.StableAttr{}) + return inode, nil, fuse.FOPEN_DIRECT_IO, 0 +} + +func (d *p9Dir) Unlink(ctx context.Context, name string) syscall.Errno { + child := p9path(d.path, name) + if err := d.fsys.Remove(child); err != nil { + return syscall.EIO + } + return 0 +} + +func (d *p9Dir) Mkdir(ctx context.Context, name string, mode uint32, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) { + child := p9path(d.path, name) + fid, err := d.fsys.Create(child, plan9.OREAD, plan9.DMDIR|plan9.Perm(mode&0777)) + if err != nil { + return nil, syscall.EIO + } + fid.Close() + out.Mode = mode&0777 | syscall.S_IFDIR + node := &p9Dir{fsys: d.fsys, path: child} + return d.NewInode(ctx, node, fs.StableAttr{Mode: syscall.S_IFDIR}), 0 +} + +func (d *p9Dir) Rmdir(ctx context.Context, name string) syscall.Errno { + child := p9path(d.path, name) + if err := d.fsys.Remove(child); err != nil { + return syscall.EIO + } + return 0 +} + +func (d *p9Dir) Rename(ctx context.Context, name string, newParent fs.InodeEmbedder, newName string, flags uint32) syscall.Errno { + child := p9path(d.path, name) + var dir plan9.Dir + dir.Null() + dir.Name = newName + if err := d.fsys.Wstat(child, &dir); err != nil { + return syscall.EIO + } + return 0 +} + // --- p9File --- func (f *p9File) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno { @@ -101,6 +172,8 @@ func (f *p9File) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOu } out.Mode = uint32(dir.Mode) & 0777 out.Size = dir.Length + out.Uid = lookupUid(dir.Uid) + out.Gid = lookupGid(dir.Gid) return 0 } @@ -126,8 +199,6 @@ func (f *p9File) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off in return nil, syscall.EIO } defer fid.Close() - // The 9fans client doesn't support Seek; read from offset by reading and discarding. - // For small offsets this is fine; large seeks on big files would need a smarter approach. if off > 0 { buf := make([]byte, min(off, 8192)) remaining := off @@ -174,3 +245,27 @@ func min(a, b int64) int64 { } return b } + +func lookupUid(name string) uint32 { + if id, err := strconv.ParseUint(name, 10, 32); err == nil { + return uint32(id) + } + if u, err := user.Lookup(name); err == nil { + if id, err := strconv.ParseUint(u.Uid, 10, 32); err == nil { + return uint32(id) + } + } + return 0 +} + +func lookupGid(name string) uint32 { + if id, err := strconv.ParseUint(name, 10, 32); err == nil { + return uint32(id) + } + if g, err := user.LookupGroup(name); err == nil { + if id, err := strconv.ParseUint(g.Gid, 10, 32); err == nil { + return uint32(id) + } + } + return 0 +} diff --git a/fs/memory.go b/fs/memory.go index 981e828..91ed72d 100644 --- a/fs/memory.go +++ b/fs/memory.go @@ -44,14 +44,14 @@ func (m *MemoryStore) Stat(name string) (os.FileInfo, error) { if !ok { return nil, fmt.Errorf("%s: not found", name) } - return &SyntheticFileInfo{Name_: name, Mode_: 0444, Size_: sz}, nil + return &SyntheticFileInfo{Name_: name, Mode_: PermIdx, Size_: sz}, nil } func (m *MemoryStore) List() ([]os.DirEntry, error) { m.mu.RLock() entries := make([]os.DirEntry, 0, len(m.entries)) for name := range m.entries { - entries = append(entries, FileEntry(name, 0444)) + entries = append(entries, FileEntry(name, PermIdx)) } m.mu.RUnlock() return entries, nil diff --git a/fs/perm.go b/fs/perm.go new file mode 100644 index 0000000..471f8b9 --- /dev/null +++ b/fs/perm.go @@ -0,0 +1,114 @@ +// Package fs permission registry. +// This is the SINGLE SOURCE OF TRUTH for all filesystem permissions. +// Every file and directory permission in the 9P tree is declared here. +// No permission literals should appear anywhere else. +package fs + +import "os" + +// Path constants for the filesystem namespace. +// +// Paths ending in "/" are directories (used as route prefixes). +// Paths without trailing "/" are scoped entries within a parent. +// +// Template variables: +// {id} — session ID (dynamic, matches any session) +// {file} — file name within a session directory +const ( + PathRoot = "/" + PathAgents = "/a/" + PathPrompts = "/p/" + PathMemory = "/m/" + PathSessions = "/s/" + PathSkills = "/sk/" + PathUtils = "/u/" + PathPlugins = "/x/" + PathTmp = "/tmp/" + PathTranscripts = "/tr/" + PathSessionDir = "s/{id}" + PathTools = "t/" + PathSessionFile = "s/{id}/{file}" +) + +// Perm declares permissions for a filesystem node. +// - DirMode: permission reported in 9P stat for the directory itself. +// - FileMode: default permission for files created/listed in this directory. +// - Files: per-file permission overrides (file name → mode). +type Perm struct { + DirMode os.FileMode + FileMode os.FileMode + Files map[string]os.FileMode +} + +// Perms is the permission registry. To find the permission for any path, +// look it up here. +var Perms = map[string]Perm{ + PathRoot: { + DirMode: 0755, + FileMode: 0444, + Files: map[string]os.FileMode{ + "backends": 0444, + "help": 0444, + }, + }, + PathAgents: {DirMode: 0775, FileMode: 0666}, + PathPrompts: {DirMode: 0755, FileMode: 0444}, + PathMemory: {DirMode: 0775, FileMode: 0664}, + PathSessions: { + DirMode: 0755, + FileMode: 0444, + Files: map[string]os.FileMode{ + "new": 0666, + "idx": 0444, + "ls": 0555, + "kill": 0555, + "sh": 0550, + "b": 0555, + "bfg": 0555, + "bbg": 0555, + "cleanup": 0555, + }, + }, + PathSkills: {DirMode: 0775, FileMode: 0664}, + PathUtils: {DirMode: 0755, FileMode: 0555}, + PathPlugins: {DirMode: 0755, FileMode: 0555}, + PathTmp: {DirMode: 0777, FileMode: 0777}, + PathTranscripts: {DirMode: 0755, FileMode: 0444}, + PathSessionDir: {DirMode: 0755}, + PathTools: {DirMode: 0755, FileMode: 0755}, + PathSessionFile: { + Files: map[string]os.FileMode{ + "plan": 0666, + "ctl": 0666, + "prompt": 0466, + "fifo.in": 0222, + "fifo.out": 0444, + "chat": 0444, + "offset": 0444, + "cfg": 0666, + "state": 0444, + "statewait": 0444, + "usage": 0444, + "cost": 0444, + "ctxsz": 0444, + "models": 0444, + "systemprompt": 0444, + "env": 0444, + "tail": 0555, + "prompt.prev": 0444, + "context": 0444, + }, + }, +} + +// Structural permissions — invariants of the Tree type itself. +const ( + PermIdx os.FileMode = 0444 // synthetic index files are always read-only + PermChildDir os.FileMode = 0555 // mounted child directories are always rx +) + +// OS-level directory creation mode (for os.MkdirAll of backing dirs on disk). +const PermMkdir os.FileMode = 0755 + +// Private directory creation mode (e.g. sessions state dir). +const PermMkdirPrivate os.FileMode = 0700 diff --git a/fs/skill.go b/fs/skill.go index 0c6eafb..21b63c0 100644 --- a/fs/skill.go +++ b/fs/skill.go @@ -57,7 +57,7 @@ func NewSkillTreeWith(cfg SkillConfig) *Tree { ss := &skillHelper{cfg: cfg} - return NewTree(cfg.Dirs, 0644, + return NewTree(cfg.Dirs, Perms[PathSkills].FileMode, WithIndex(ss.index), WithResolver(ss.resolve), WithLister(ss.list), @@ -121,7 +121,7 @@ func (ss *skillHelper) resolve(_ []string, name string) (string, error) { func (ss *skillHelper) list(_ []string) ([]os.DirEntry, error) { var result []os.DirEntry for _, m := range ss.listSkills() { - result = append(result, FileEntry(m.Name+".md", 0666)) + result = append(result, FileEntry(m.Name+".md", Perms[PathSkills].FileMode)) } return result, nil } @@ -129,10 +129,10 @@ func (ss *skillHelper) list(_ []string) ([]os.DirEntry, error) { func (ss *skillHelper) create(_ []string, name string, _ os.FileMode) error { skillName := strings.TrimSuffix(name, ".md") dir := filepath.Join(ss.cfg.Dirs[0], skillName) - if err := ss.cfg.MkdirAll(dir, 0755); err != nil { + if err := ss.cfg.MkdirAll(dir, PermMkdir); err != nil { return err } - return ss.cfg.WriteFile(filepath.Join(dir, "SKILL.md"), nil, 0644) + return ss.cfg.WriteFile(filepath.Join(dir, "SKILL.md"), nil, Perms[PathSkills].FileMode) } func (ss *skillHelper) del(_ []string, name string) error { diff --git a/fs/tree.go b/fs/tree.go index 80e451d..e2b1981 100644 --- a/fs/tree.go +++ b/fs/tree.go @@ -160,12 +160,12 @@ func (d *Tree) Stat(name string) (os.FileInfo, error) { // Check children first. if child, rest, ok := d.split(name); ok { if rest == "" { - return &SyntheticFileInfo{Name_: name, Mode_: 0555, IsDir_: true}, nil + return &SyntheticFileInfo{Name_: name, Mode_: PermChildDir, IsDir_: true}, nil } return child.Stat(rest) } if name == "idx" && d.indexFn != nil { - return &SyntheticFileInfo{Name_: "idx", Mode_: 0444}, nil + return &SyntheticFileInfo{Name_: "idx", Mode_: PermIdx}, nil } // Custom stat hook. if d.statFn != nil { @@ -201,19 +201,19 @@ func (d *Tree) List() ([]os.DirEntry, error) { } if d.indexFn != nil { result := make([]os.DirEntry, 0, len(entries)+1) - result = append(result, FileEntry("idx", 0444)) + result = append(result, FileEntry("idx", PermIdx)) result = append(result, entries...) entries = result } for name := range d.children { - entries = append(entries, DirEntry(name, 0555)) + entries = append(entries, DirEntry(name, PermChildDir)) } return entries, nil } seen := make(map[string]bool) var result []os.DirEntry if d.indexFn != nil { - result = append(result, FileEntry("idx", 0444)) + result = append(result, FileEntry("idx", PermIdx)) seen["idx"] = true } for _, dir := range d.Tree { @@ -230,7 +230,7 @@ func (d *Tree) List() ([]os.DirEntry, error) { } for name := range d.children { if !seen[name] { - result = append(result, DirEntry(name, 0555)) + result = append(result, DirEntry(name, PermChildDir)) } } return result, nil @@ -249,7 +249,7 @@ func (d *Tree) Open(name string) (File, error) { } if name == "idx" && d.indexFn != nil { return &FileConfig{ - StatFn: func() (os.FileInfo, error) { return &SyntheticFileInfo{Name_: "idx", Mode_: 0444}, nil }, + StatFn: func() (os.FileInfo, error) { return &SyntheticFileInfo{Name_: "idx", Mode_: PermIdx}, nil }, ReadFn: func() ([]byte, error) { return d.indexFn(d) }, WriteFn: func([]byte) error { return fmt.Errorf("idx: read-only") }, BlockingReadFn: notBlocking, @@ -271,7 +271,7 @@ func (d *Tree) Open(name string) (File, error) { if d.readOnly { return fmt.Errorf("%s: read-only", name) } - if err := os.MkdirAll(filepath.Dir(wp), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(wp), PermMkdir); err != nil { return err } return os.WriteFile(wp, data, d.perm) @@ -295,7 +295,7 @@ func (d *Tree) Create(name string) error { return d.createFn(d.Tree, name, d.perm) } p := d.writePath(name) - if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(p), PermMkdir); err != nil { return err } return os.WriteFile(p, nil, d.perm) @@ -364,5 +364,5 @@ func (d *Tree) MkdirAll(rel string) error { if len(d.Tree) == 0 { return fmt.Errorf("no backing directory") } - return os.MkdirAll(filepath.Join(d.Tree[0], rel), 0755) + return os.MkdirAll(filepath.Join(d.Tree[0], rel), PermMkdir) } \ No newline at end of file diff --git a/main.go b/main.go index c14c5d6..ef128ea 100644 --- a/main.go +++ b/main.go @@ -95,7 +95,7 @@ func cmdMount() { home, _ := os.UserHomeDir() mnt = filepath.Join(home, "mnt", addr) } - if err := os.MkdirAll(mnt, 0755); err != nil { + if err := os.MkdirAll(mnt, fs.PermMkdir); err != nil { fmt.Fprintf(os.Stderr, "cannot create mount dir: %v\n", err) os.Exit(1) } @@ -167,21 +167,21 @@ func runServer(sockPath, pidPath string) { } // Write PID file - os.WriteFile(pidPath, []byte(fmt.Sprintf("%d", os.Getpid())), 0644) //nolint:errcheck + os.WriteFile(pidPath, []byte(fmt.Sprintf("%d", os.Getpid())), fs.Perms[fs.PathRoot].FileMode) //nolint:errcheck sink := olog.NewSink(os.Stdout, os.Stderr, olog.ParseLevel(os.Getenv("OLLIE_LOG"), olog.LevelWarn)) memDir := defaultMemDir() - os.MkdirAll(memDir, 0755) //nolint:errcheck + os.MkdirAll(memDir, fs.PermMkdir) //nolint:errcheck transcriptDir := defaultTranscriptDir() - os.MkdirAll(transcriptDir, 0755) //nolint:errcheck + os.MkdirAll(transcriptDir, fs.PermMkdir) //nolint:errcheck tmpDir := defaultTmpDir() - os.MkdirAll(tmpDir, 0755) //nolint:errcheck + os.MkdirAll(tmpDir, fs.PermMkdir) //nolint:errcheck agentsDirs := agent.AgentsDirs() sessionsDir := paths.CfgDir() + "/sessions" - toolTree := fs.NewTree([]string{execute.ToolsPath()}, 0755, fs.WithIndex(ToolIndex)) - transcriptStore := fs.NewTree([]string{transcriptDir}, 0444, fs.WithReadOnly()) + toolTree := fs.NewTree([]string{execute.ToolsPath()}, fs.Perms[fs.PathTools].FileMode, fs.WithIndex(ToolIndex)) + transcriptStore := fs.NewTree([]string{transcriptDir}, fs.Perms[fs.PathTranscripts].FileMode, fs.WithReadOnly()) mgr := session.NewManager(session.ManagerConfig{ AgentsDir: agentsDirs[0], @@ -207,16 +207,16 @@ func runServer(sockPath, pidPath string) { srv := server.New(server.Config{ Sink: sink, - AgentStore: fs.NewTree(agentsDirs, 0644), - PromptStore: fs.NewTree(agent.PromptsDirs(), 0444, fs.WithReadOnly()), - MemStore: fs.NewTree([]string{memDir}, 0644), + AgentStore: fs.NewTree(agentsDirs, fs.Perms[fs.PathAgents].FileMode), + PromptStore: fs.NewTree(agent.PromptsDirs(), fs.Perms[fs.PathPrompts].FileMode, fs.WithReadOnly()), + MemStore: fs.NewTree([]string{memDir}, fs.Perms[fs.PathMemory].FileMode), ToolTree: toolTree, - UtilStore: fs.NewTree([]string{paths.CfgDir() + "/scripts/u"}, 0555, fs.WithReadOnly()), - PluginStore: fs.NewTree([]string{execute.PluginsPath()}, 0555, fs.WithReadOnly()), + UtilStore: fs.NewTree([]string{paths.CfgDir() + "/scripts/u"}, fs.Perms[fs.PathUtils].FileMode, fs.WithReadOnly()), + PluginStore: fs.NewTree([]string{execute.PluginsPath()}, fs.Perms[fs.PathPlugins].FileMode, fs.WithReadOnly()), SkillTree: fs.NewSkillTree(), SessionMgr: mgr, TranscriptStore: transcriptStore, - TmpStore: fs.NewTree([]string{tmpDir}, 0600), + TmpStore: fs.NewTree([]string{tmpDir}, fs.Perms[fs.PathTmp].FileMode), RootStore: NewRootStore(), }) @@ -267,7 +267,7 @@ func runServer(sockPath, pidPath string) { mnt = filepath.Join(home, "mnt", "ollie") } var fuseCmd *exec.Cmd - if err := os.MkdirAll(mnt, 0755); err != nil { + if err := os.MkdirAll(mnt, fs.PermMkdir); err != nil { fmt.Fprintf(os.Stderr, "warning: cannot create mount dir: %v\n", err) } else { fuseCmd = exec.Command("9pfuse", sockPath, mnt) @@ -381,7 +381,7 @@ func NewRootStore() *fs.Tree { } readOnly := func([]byte) error { return fmt.Errorf("read-only") } - return fs.NewTree(nil, 0444, + return fs.NewTree(nil, fs.Perms[fs.PathRoot].FileMode, fs.WithReadOnly(), fs.WithResolver(func(_ []string, name string) (string, error) { if _, ok := entries[name]; ok { @@ -391,8 +391,8 @@ func NewRootStore() *fs.Tree { }), fs.WithLister(func(_ []string) ([]os.DirEntry, error) { return []os.DirEntry{ - fs.FileEntry("backends", 0444), - fs.FileEntry("help", 0444), + fs.FileEntry("backends", fs.Perms[fs.PathRoot].FileMode), + fs.FileEntry("help", fs.Perms[fs.PathRoot].FileMode), }, nil }), fs.WithOpener(func(_ []string, name string) (fs.File, error) { @@ -401,7 +401,7 @@ func NewRootStore() *fs.Tree { return nil, fmt.Errorf("%s: not found", name) } return &fs.FileConfig{ - StatFn: func() (os.FileInfo, error) { return &fs.SyntheticFileInfo{Name_: name, Mode_: 0444}, nil }, + StatFn: func() (os.FileInfo, error) { return &fs.SyntheticFileInfo{Name_: name, Mode_: fs.Perms[fs.PathRoot].FileMode}, nil }, ReadFn: readFn, WriteFn: readOnly, BlockingReadFn: notBlocking, diff --git a/server/server.go b/server/server.go index 7f124c5..2b5fe5d 100644 --- a/server/server.go +++ b/server/server.go @@ -115,24 +115,26 @@ func New(cfg Config) *Server { return s } -// fsRoute maps a path prefix to its backing fs. +// fsRoute maps a path prefix to its backing fs and directory permissions. type fsRoute struct { - prefix string - tree func() FileTree + prefix string + dirMode os.FileMode + tree func() FileTree } // routes returns the route table for the file tree namespace. +// Directory permissions come from the fs.Perms registry. func (s *Server) routes() []fsRoute { return []fsRoute{ - {"/a/", func() FileTree { return s.agentStore }}, - {"/p/", func() FileTree { return s.promptStore }}, - {"/m/", func() FileTree { return s.memStore }}, - {"/s/", func() FileTree { return s.sessionMgr.Tree() }}, - {"/sk/", func() FileTree { return s.skillTree }}, - {"/u/", func() FileTree { return s.utilStore }}, - {"/x/", func() FileTree { return s.pluginStore }}, - {"/tmp/", func() FileTree { return s.tmpStore }}, - {"/tr/", func() FileTree { return s.transcriptStore }}, + {fs.PathAgents, fs.Perms[fs.PathAgents].DirMode, func() FileTree { return s.agentStore }}, + {fs.PathPrompts, fs.Perms[fs.PathPrompts].DirMode, func() FileTree { return s.promptStore }}, + {fs.PathMemory, fs.Perms[fs.PathMemory].DirMode, func() FileTree { return s.memStore }}, + {fs.PathSessions, fs.Perms[fs.PathSessions].DirMode, func() FileTree { return s.sessionMgr.Tree() }}, + {fs.PathSkills, fs.Perms[fs.PathSkills].DirMode, func() FileTree { return s.skillTree }}, + {fs.PathUtils, fs.Perms[fs.PathUtils].DirMode, func() FileTree { return s.utilStore }}, + {fs.PathPlugins, fs.Perms[fs.PathPlugins].DirMode, func() FileTree { return s.pluginStore }}, + {fs.PathTmp, fs.Perms[fs.PathTmp].DirMode, func() FileTree { return s.tmpStore }}, + {fs.PathTranscripts, fs.Perms[fs.PathTranscripts].DirMode, func() FileTree { return s.transcriptStore }}, } } @@ -172,6 +174,52 @@ func (s *Server) routeDir(path string) FileTree { return nil } +// dirMode returns the directory permission for a path. +// Top-level directories get their mode from the route table. +// Subdirectories within /s/ have specific overrides for tools. +func (s *Server) dirMode(path string) plan9.Perm { + for _, r := range s.routes() { + if path+"/" == r.prefix { + return plan9.Perm(r.dirMode) + } + if strings.HasPrefix(path, r.prefix) { + // Subdirectory within a route — check for /s/{id}/t + if r.prefix == fs.PathSessions { + rel := strings.TrimPrefix(path, fs.PathSessions) + parts := strings.SplitN(rel, "/", 3) + if len(parts) >= 2 && parts[1] == "t" { + return plan9.Perm(fs.Perms[fs.PathTools].DirMode) + } + } + return plan9.Perm(r.dirMode) + } + } + return plan9.Perm(fs.Perms[fs.PathRoot].DirMode) +} + +// filePerm returns the file permission for a path from the permission registry. +func (s *Server) filePerm(path string) os.FileMode { + // Session files have per-file overrides. + if strings.HasPrefix(path, fs.PathSessions) { + base := pathBase(path) + if m, ok := fs.Perms[fs.PathSessionFile].Files[base]; ok { + return m + } + if m, ok := fs.Perms[fs.PathSessions].Files[base]; ok { + return m + } + return fs.Perms[fs.PathSessions].FileMode + } + for _, r := range s.routes() { + if strings.HasPrefix(path, r.prefix) { + if p, ok := fs.Perms[r.prefix]; ok { + return p.FileMode + } + } + } + return fs.PermIdx +} + // AddGroup adds a user to a group. func (s *Server) AddGroup(group, user string) { s.mu.Lock() @@ -205,22 +253,19 @@ func (s *Server) InGroup(group, user string) bool { // Session namespace (/s/{sid}/**) is owned by the agent principal with group "agent". // Everything else is owned by the current system user and their primary group. func (s *Server) fileOwnerGroup(path string) (uid, gid string) { - if strings.HasPrefix(path, "/s/") { - parts := strings.SplitN(strings.TrimPrefix(path, "/s/"), "/", 2) + if strings.HasPrefix(path, fs.PathSessions) { + parts := strings.SplitN(strings.TrimPrefix(path, fs.PathSessions), "/", 2) if len(parts) >= 1 && parts[0] != "new" && !isSessionFile(path) { if sess := s.sessionMgr.Session(parts[0]); sess != nil { return sess.Uname(), "agent" } } } + if u, err := user.Current(); err == nil { - gid := u.Gid - if g, err := user.LookupGroupId(u.Gid); err == nil { - gid = g.Name - } - return u.Username, gid + return u.Username, "agent" } - return "ollie", "ollie" + return "ollie", "agent" } // checkPerm verifies that uname has the requested access (mode) to path. @@ -404,7 +449,7 @@ func (s *Server) handle(cs *connState, fc *plan9.Fcall, ctx context.Context) *pl // isSessionFile reports whether path is a fixed file directly under /s/ // (i.e. /s/ where name is in sessionStoreFiles). func isSessionFile(path string) bool { - name, ok := strings.CutPrefix(path, "/s/") + name, ok := strings.CutPrefix(path, fs.PathSessions) if !ok || strings.Contains(name, "/") { return false } @@ -488,13 +533,10 @@ func (s *Server) attach(cs *connState, fc *plan9.Fcall) *plan9.Fcall { qid := plan9.Qid{Type: QTDir, Path: 0} cs.fids[fc.Fid] = &fid{path: "/", qid: qid} s.log.Debug("Tattach uname=%q", fc.Uname) - // Assign group membership based on whether uname is a session principal. + // All clients get agent group membership so they can access shared + // directories. Owner bits handle user-vs-agent distinction. if fc.Uname != "" { - if s.sessionMgr.SessionByUname(fc.Uname) != nil { - s.AddGroup("agent", fc.Uname) - } else { - s.AddGroup("user", fc.Uname) - } + s.AddGroup("agent", fc.Uname) } return &plan9.Fcall{Type: plan9.Rattach, Tag: fc.Tag, Qid: qid} } @@ -811,7 +853,7 @@ func (s *Server) wstat(cs *connState, fc *plan9.Fcall) *plan9.Fcall { c.mu.Unlock() } // Update group membership for session renames. - if r.prefix == "/s/" && !strings.Contains(relPath, "/") { + if r.prefix == fs.PathSessions && !strings.Contains(relPath, "/") { s.RemoveGroup("agent", oldName) s.AddGroup("agent", newDir.Name) } @@ -993,41 +1035,11 @@ func (s *Server) makeStat(path string) plan9.Dir { var mode plan9.Perm if isDir { qid.Type = QTDir - if strings.HasPrefix(path, "/s/") { - parts := strings.SplitN(strings.TrimPrefix(path, "/s/"), "/", 3) - if (len(parts) == 2 && parts[1] == "t") || (len(parts) == 3 && parts[1] == "t") { - mode = plan9.DMDIR | 0500 // rx owner only - } else { - mode = plan9.DMDIR | 0755 - } - } else if path == "/s" { - mode = plan9.DMDIR | 0755 - } else if path == "/u" || path == "/x" { - mode = plan9.DMDIR | 0555 - } else if path == "/p" { - mode = plan9.DMDIR | 0664 - } else if path == "/a" { - mode = plan9.DMDIR | 0744 - } else if path == "/sk" { - mode = plan9.DMDIR | 0754 - } else if path == "/tmp" { - mode = plan9.DMDIR | 0777 - } else if path == "/tr" { - mode = plan9.DMDIR | 0766 - } else { - mode = plan9.DMDIR | 0755 - } + mode = plan9.DMDIR | s.dirMode(path) } else { - // Delegate to the backing tree for file mode. - if tree, name := s.route(path); tree != nil { - if info, err := tree.Stat(name); err == nil { - mode = plan9.Perm(info.Mode()) - } else { - mode = 0444 - } - } else { - mode = 0444 - } + // Use the permission registry for file mode rather than os.Stat + // (which is affected by umask and may not reflect intended perms). + mode = plan9.Perm(s.filePerm(path)) } uid, gid := s.fileOwnerGroup(path) @@ -1043,7 +1055,7 @@ func (s *Server) makeStat(path string) plan9.Dir { // For chat and tailable mutable files, report actual size and // Qid version so polling tools (tail -f) can detect changes via stat. // Path format: /s/{sessid}/{file} - if strings.HasPrefix(path, "/s/") { + if strings.HasPrefix(path, fs.PathSessions) { parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3) if len(parts) == 3 && parts[0] == "s" { if sess := s.sessionMgr.Session(parts[1]); sess != nil { @@ -1057,7 +1069,7 @@ func (s *Server) makeStat(path string) plan9.Dir { } // For memory and plan files, report real size and timestamps from the fs. - if strings.HasPrefix(path, "/m/") { + if strings.HasPrefix(path, fs.PathMemory) { if info, err := s.memStore.Stat(base); err == nil { dir.Length = uint64(info.Size()) dir.Atime = uint32(info.ModTime().Unix()) @@ -1079,47 +1091,47 @@ func (s *Server) makeStat(path string) plan9.Dir { if info, err := os.Stat(s.helpPath()); err == nil { dir.Length = uint64(info.Size()) } - case strings.HasPrefix(path, "/a/"): + case strings.HasPrefix(path, fs.PathAgents): if info, err := s.agentStore.Stat(base); err == nil { dir.Length = uint64(info.Size()) } - case strings.HasPrefix(path, "/p/"): + case strings.HasPrefix(path, fs.PathPrompts): if info, err := s.promptStore.Stat(base); err == nil { dir.Length = uint64(info.Size()) } - case strings.HasPrefix(path, "/sk/"): + case strings.HasPrefix(path, fs.PathSkills): if content, err := readFile(s.skillTree, base); err == nil { dir.Length = uint64(len(content)) } - case strings.HasPrefix(path, "/s/") && strings.Contains(path, "/t/"): - parts := strings.SplitN(strings.TrimPrefix(path, "/s/"), "/", 3) + case strings.HasPrefix(path, fs.PathSessions) && strings.Contains(path, "/t/"): + parts := strings.SplitN(strings.TrimPrefix(path, fs.PathSessions), "/", 3) if len(parts) == 3 && parts[1] == "t" { if content, err := readFile(s.toolTree, parts[2]); err == nil { dir.Length = uint64(len(content)) } } - case strings.HasPrefix(path, "/u/"): + case strings.HasPrefix(path, fs.PathUtils): if content, err := readFile(s.utilStore, base); err == nil { dir.Length = uint64(len(content)) } - case strings.HasPrefix(path, "/x/"): + case strings.HasPrefix(path, fs.PathPlugins): if content, err := readFile(s.pluginStore, base); err == nil { dir.Length = uint64(len(content)) } - case strings.HasPrefix(path, "/tmp/"): + case strings.HasPrefix(path, fs.PathTmp): if info, err := s.tmpStore.Stat(base); err == nil { dir.Length = uint64(info.Size()) dir.Atime = uint32(info.ModTime().Unix()) dir.Mtime = uint32(info.ModTime().Unix()) } - case strings.HasPrefix(path, "/tr/"): + case strings.HasPrefix(path, fs.PathTranscripts): if info, err := s.transcriptStore.Stat(base); err == nil { dir.Length = uint64(info.Size()) dir.Atime = uint32(info.ModTime().Unix()) dir.Mtime = uint32(info.ModTime().Unix()) } - case strings.HasPrefix(path, "/s/"): - name := strings.TrimPrefix(path, "/s/") + case strings.HasPrefix(path, fs.PathSessions): + name := strings.TrimPrefix(path, fs.PathSessions) if info, err := s.sessionMgr.Tree().Stat(name); err == nil { dir.Length = uint64(info.Size()) } diff --git a/session/session.go b/session/session.go index c78566f..624e425 100644 --- a/session/session.go +++ b/session/session.go @@ -112,25 +112,14 @@ func (sess *Session) LogInfo() (length int, vers uint32) { return len(sess.log), sess.logVers } -// sessionStoreFiles maps fixed file entries in s/ to their permissions. -var sessionStoreFiles = map[string]os.FileMode{ - "new": 0666, - "idx": 0444, - "ls": 0555, - "kill": 0555, - "sh": 0550, - "b": 0555, - "bfg": 0555, - "bbg": 0555, - "cleanup": 0555, -} + var sessionStoreOrder = []string{"new", "idx", "ls", "kill", "sh", "b", "bfg", "bbg", "cleanup"} // FileMode returns the mode for a fixed session file, // or 0 and false if the name is not a fixed file. func FileMode(name string) (os.FileMode, bool) { - m, ok := sessionStoreFiles[name] + m, ok := fs.Perms[fs.PathSessions].Files[name] return m, ok } @@ -204,11 +193,11 @@ func (s *Manager) AddSession(sess *Session) { func (s *Manager) list() ([]os.DirEntry, error) { entries := make([]os.DirEntry, 0, len(sessionStoreOrder)) for _, name := range sessionStoreOrder { - entries = append(entries, fs.FileEntry(name, sessionStoreFiles[name])) + entries = append(entries, fs.FileEntry(name, fs.Perms[fs.PathSessions].Files[name])) } s.mu.RLock() for id := range s.sessions { - entries = append(entries, fs.DirEntry(id, 0555)) + entries = append(entries, fs.DirEntry(id, fs.Perms[fs.PathSessionDir].DirMode)) } s.mu.RUnlock() return entries, nil @@ -232,7 +221,7 @@ func (s *Manager) Readdir(name string) ([]os.DirEntry, error) { if err != nil { return nil, err } - entries = append(entries, fs.DirEntry("t", 0500)) + entries = append(entries, fs.DirEntry("t", fs.Perms[fs.PathTools].DirMode)) return entries, nil } // {id}/t — list tools (filtered by allowTools) @@ -253,7 +242,7 @@ func (s *Manager) Readdir(name string) ([]os.DirEntry, error) { func (s *Manager) stat(name string) (os.FileInfo, error) { // Top-level fixed files (new, idx, sh, etc.) - if mode, ok := sessionStoreFiles[name]; ok { + if mode, ok := fs.Perms[fs.PathSessions].Files[name]; ok { return &fs.SyntheticFileInfo{Name_: name, Mode_: mode}, nil } parts := strings.SplitN(name, "/", 3) @@ -266,12 +255,12 @@ func (s *Manager) stat(name string) (os.FileInfo, error) { } // Session directory: {id} if len(parts) == 1 { - return &fs.SyntheticFileInfo{Name_: sessID, Mode_: 0555, IsDir_: true}, nil + return &fs.SyntheticFileInfo{Name_: sessID, Mode_: fs.Perms[fs.PathSessionDir].DirMode, IsDir_: true}, nil } // Tools directory: {id}/t if parts[1] == "t" { if len(parts) == 2 { - return &fs.SyntheticFileInfo{Name_: "t", Mode_: 0500, IsDir_: true}, nil + return &fs.SyntheticFileInfo{Name_: "t", Mode_: fs.Perms[fs.PathTools].DirMode, IsDir_: true}, nil } // Tool file: {id}/t/{rel} if s.cfg.ToolTree != nil { @@ -296,7 +285,7 @@ func (s *Manager) openEntry(name string) (fs.File, error) { switch name { case "new": return &fs.FileConfig{ - StatFn: func() (os.FileInfo, error) { return &fs.SyntheticFileInfo{Name_: "new", Mode_: 0666}, nil }, + StatFn: func() (os.FileInfo, error) { return &fs.SyntheticFileInfo{Name_: "new", Mode_: fs.Perms[fs.PathSessions].Files["new"]}, nil }, ReadFn: func() ([]byte, error) { return []byte("name=\ncwd=\nbackend=\nmodel=\nagent=\nmaxTokens=\nmaxCompletionTokens=\ntemperature=\ntopP=\ntopK=\nminP=\ntopA=\nfrequencyPenalty=\npresencePenalty=\nrepetitionPenalty=\nreasoning=\nreasoningEffort=\nincludeReasoning=\nresponseFormat=\nstop=\nverbosity=\n"), nil }, @@ -307,15 +296,15 @@ func (s *Manager) openEntry(name string) (fs.File, error) { }, nil case "idx": return &fs.FileConfig{ - StatFn: func() (os.FileInfo, error) { return &fs.SyntheticFileInfo{Name_: "idx", Mode_: 0444}, nil }, + StatFn: func() (os.FileInfo, error) { return &fs.SyntheticFileInfo{Name_: "idx", Mode_: fs.Perms[fs.PathSessions].Files["idx"]}, nil }, ReadFn: func() ([]byte, error) { return s.index(), nil }, WriteFn: func([]byte) error { return fmt.Errorf("idx: read-only") }, BlockingReadFn: notBlocking, }, nil } - if _, ok := sessionStoreFiles[name]; ok { + if _, ok := fs.Perms[fs.PathSessions].Files[name]; ok { return &fs.FileConfig{ - StatFn: func() (os.FileInfo, error) { return &fs.SyntheticFileInfo{Name_: name, Mode_: sessionStoreFiles[name]}, nil }, + StatFn: func() (os.FileInfo, error) { return &fs.SyntheticFileInfo{Name_: name, Mode_: fs.Perms[fs.PathSessions].Files[name]}, nil }, ReadFn: func() ([]byte, error) { return s.cfg.ReadFile(paths.CfgDir() + "/scripts/s/" + name) }, @@ -618,7 +607,7 @@ func (s *Manager) createSession(args []string) error { be.SetModel(modelOverride) } - if err := s.cfg.MkdirAll(s.cfg.SessionsDir, 0700); err != nil { + if err := s.cfg.MkdirAll(s.cfg.SessionsDir, fs.PermMkdirPrivate); err != nil { return fmt.Errorf("sessions dir: %w", err) } diff --git a/session/sessionfile.go b/session/sessionfile.go index 7b807ad..63b87bf 100644 --- a/session/sessionfile.go +++ b/session/sessionfile.go @@ -15,39 +15,45 @@ import ( ) // FileList defines the fixed set of files in a session directory. +// Permissions come from fs.Perms[fs.PathSessionFile].Files. var FileList = []struct { Name string - Mode os.FileMode OneShot bool Async bool }{ - {"plan", 0666, false, false}, - {"ctl", 0666, false, true}, - {"prompt", 0466, false, true}, - {"fifo.in", 0222, false, true}, - {"fifo.out", 0444, true, false}, - {"chat", 0444, false, false}, - {"offset", 0444, false, false}, - {"cfg", 0666, false, false}, - {"state", 0444, false, false}, - {"statewait", 0444, false, false}, - {"usage", 0444, false, false}, - {"cost", 0444, false, false}, - {"ctxsz", 0444, false, false}, - {"models", 0444, false, false}, - {"systemprompt", 0444, false, false}, - {"env", 0444, false, false}, - {"tail", 0555, false, false}, - {"prompt.prev", 0444, false, false}, - {"context", 0444, false, false}, + {"plan", false, false}, + {"ctl", false, true}, + {"prompt", false, true}, + {"fifo.in", false, true}, + {"fifo.out", true, false}, + {"chat", false, false}, + {"offset", false, false}, + {"cfg", false, false}, + {"state", false, false}, + {"statewait", false, false}, + {"usage", false, false}, + {"cost", false, false}, + {"ctxsz", false, false}, + {"models", false, false}, + {"systemprompt", false, false}, + {"env", false, false}, + {"tail", false, false}, + {"prompt.prev", false, false}, + {"context", false, false}, +} + +// sessionFilePerms returns the permission registry for per-session files. +func sessionFilePerms() map[string]os.FileMode { + return fs.Perms[fs.PathSessionFile].Files } func NewSessionTree(sess *Session, log *olog.Logger, kill func(), rename func(newID string) error, saveTranscript func([]byte) error) *fs.Tree { h := &sessionHelper{sess: sess, log: log, kill: kill, rename: rename, saveTranscript: saveTranscript} + perms := sessionFilePerms() specs := make([]fs.FileSpec, len(FileList)) for i, f := range FileList { - specs[i] = h.fileSpec(f.Name, f.Mode) + specs[i] = h.fileSpec(f.Name, perms[f.Name]) specs[i].OneShot = f.OneShot specs[i].Async = f.Async }