fs: centralized permission registry; FUSE mutating operations

This commit is contained in:
Levi Neely 2026-05-21 11:50:34 +02:00
parent e77c5a09f4
commit 09e4f0f678
10 changed files with 375 additions and 158 deletions

View File

@ -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{

View File

@ -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
}

View File

@ -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

114
fs/perm.go Normal file
View File

@ -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

View File

@ -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 {

View File

@ -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)
}

36
main.go
View File

@ -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,

View File

@ -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/<name> 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())
}

View File

@ -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)
}

View File

@ -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
}