// 9P filesystem server for ollie sessions. // See doc comment below for filesystem layout. package main import ( "context" "fmt" "hash/fnv" "io" "net" "os" "path/filepath" "strings" "sync" "time" "ollie/pkg/agent" "ollie/pkg/backend" olog "ollie/pkg/log" "ollie/pkg/paths" "ollie/pkg/tools/execute" "olliesrv/store" "9fans.net/go/plan9" ) // Re-export store types for use in server.go. type ( StoreEntry = store.StoreEntry Store = store.Store RunnableStore = store.RunnableStore FlatDirStore = store.Store Session = store.Session SessionStore = store.SessionStore syntheticFileInfo = store.SyntheticFileInfo ) func NewFlatDirStore(dir string, perm os.FileMode) FlatDirStore { return store.NewFlatDir(dir, perm) } func NewSkillStore() Store { return store.NewSkillStore() } func syntheticEntry(name string, mode os.FileMode) os.DirEntry { return store.FileEntry(name, mode) } func syntheticDirEntry(name string, mode os.FileMode) os.DirEntry { return store.DirEntry(name, mode) } // --- util --- // UtilStore is a FlatDirStore backed by the scripts/u/ directory. type UtilStore struct { FlatDirStore } func NewUtilStore() *UtilStore { return &UtilStore{FlatDirStore: NewFlatDirStore(paths.CfgDir()+"/scripts/u", 0555)} } func (s *UtilStore) Stat(name string) (os.FileInfo, error) { return s.FlatDirStore.Stat(name) } func (s *UtilStore) List() ([]os.DirEntry, error) { return s.FlatDirStore.List() } func (s *UtilStore) Open(name string) (StoreEntry, error) { return s.FlatDirStore.Open(name) } // --- exec --- // ExecStore is a read-only FlatDirStore backed by the scripts/x/ directory. type ExecStore struct { FlatDirStore } func NewExecStore() *ExecStore { return &ExecStore{FlatDirStore: NewFlatDirStore(execute.PluginsPath(), 0555)} } func (s *ExecStore) Stat(name string) (os.FileInfo, error) { return s.FlatDirStore.Stat(name) } func (s *ExecStore) List() ([]os.DirEntry, error) { return s.FlatDirStore.List() } func (s *ExecStore) Open(name string) (StoreEntry, error) { return s.FlatDirStore.Open(name) } // --- tools --- // ToolStore is a BlobStore backed by the tools directory. // It extends FlatDirStore with a synthetic "idx" entry that lists all tools // with their descriptions and argument signatures. // It supports subdirectories (e.g. _lib/) for shared library code. type ToolStore struct { FlatDirStore dir string } func NewToolStore() *ToolStore { dir := execute.ToolsPath() return &ToolStore{FlatDirStore: NewFlatDirStore(dir, 0755), dir: dir} } // ListDir lists entries in a subdirectory relative to the tools root. func (s *ToolStore) ListDir(rel string) ([]os.DirEntry, error) { return os.ReadDir(filepath.Join(s.dir, rel)) } func (s *ToolStore) Stat(name string) (os.FileInfo, error) { if name == "idx" { return &syntheticFileInfo{Name_: "idx", Mode_: 0444}, nil } return s.FlatDirStore.Stat(name) } func (s *ToolStore) List() ([]os.DirEntry, error) { entries, err := s.FlatDirStore.List() result := make([]os.DirEntry, 0, len(entries)+1) result = append(result, syntheticEntry("idx", 0444)) result = append(result, entries...) return result, err } func (s *ToolStore) Open(name string) (StoreEntry, error) { if name == "idx" { return &store.EntryConfig{ StatFn: func() (os.FileInfo, error) { return &syntheticFileInfo{Name_: "idx", Mode_: 0444}, nil }, ReadFn: func() ([]byte, error) { return s.index() }, WriteFn: func([]byte) error { return fmt.Errorf("idx: read-only") }, BlockingReadFn: func(context.Context, string) ([]byte, string, error) { return nil, "", fmt.Errorf("blocking read not supported") }, }, nil } return s.FlatDirStore.Open(name) } func (s *ToolStore) index() ([]byte, error) { entries, err := s.FlatDirStore.List() if err != nil { return nil, err } var sb strings.Builder for _, e := range entries { if e.IsDir() { continue } data, err := storeRead(s.FlatDirStore, e.Name()) if err != nil { continue } var desc, args string for line := range strings.SplitSeq(string(data), "\n") { if d, ok := strings.CutPrefix(line, "# description:"); ok { desc = strings.TrimSpace(d) } else if a, ok := strings.CutPrefix(line, "# Args:"); ok { args = strings.TrimSpace(a) } else if a, ok := strings.CutPrefix(line, "# args:"); ok { args = strings.TrimSpace(a) } if !strings.HasPrefix(line, "#") && line != "" { break } } if desc == "" { continue } fmt.Fprintf(&sb, "## %s\n", e.Name()) fmt.Fprintf(&sb, "description: %s\n", desc) if args != "" { fmt.Fprintf(&sb, "args: %s\n", args) } sb.WriteString("\n") } return []byte(sb.String()), nil } const ( QTDir = plan9.QTDIR QTFile = plan9.QTFILE ) // fid tracks per-descriptor state for a single 9P connection. type fid struct { path string qid plan9.Qid mode uint8 writeBuf []byte waitBase string // for *wait files: value snapshotted at open time } // connState tracks all open fids for a single 9P connection. type connState struct { mu sync.RWMutex fids map[uint32]*fid ctx context.Context cancel context.CancelFunc pending map[uint16]context.CancelFunc // in-flight request cancels, keyed by tag uname string // user principal from Tattach } // Server is the 9P server for ollie sessions. type Server struct { mu sync.RWMutex conns []*connState log *olog.Logger sink *olog.Sink agentsDir string // kept for session creation wiring agentStore Store promptStore Store memStore Store toolStore *ToolStore utilStore Store pluginStore Store skillStore Store sessionStore *SessionStore transcriptStore Store tmpStore Store strict bool yolo bool groups map[string]map[string]bool // group → set of members } // ServerOption configures the 9P server. type ServerOption func(*Server) func WithStrict() ServerOption { return func(s *Server) { s.strict = true } } func WithYolo() ServerOption { return func(s *Server) { s.yolo = true } } // New creates a new Server. func New(sink *olog.Sink, opts ...ServerOption) *Server { memDir := defaultMemDir() os.MkdirAll(memDir, 0755) //nolint:errcheck transcriptDir := defaultTranscriptDir() os.MkdirAll(transcriptDir, 0755) //nolint:errcheck tmpDir := defaultTmpDir() os.MkdirAll(tmpDir, 0755) //nolint:errcheck agentsDirs := agent.AgentsDirs() agentsDir := agentsDirs[0] sessionsDir := paths.CfgDir() + "/sessions" s := &Server{ log: sink.Logger("9p", olog.LevelDebug), sink: sink, agentsDir: agentsDir, agentStore: store.NewFlatDirWritableUnion(agentsDirs, 0644), promptStore: store.NewFlatDirUnion(agent.PromptsDirs(), 0444), memStore: NewFlatDirStore(memDir, 0644), toolStore: NewToolStore(), utilStore: NewUtilStore(), pluginStore: NewExecStore(), skillStore: NewSkillStore(), transcriptStore: NewFlatDirStore(transcriptDir, 0444), tmpStore: NewFlatDirStore(tmpDir, 0600), groups: make(map[string]map[string]bool), } for _, o := range opts { o(s) } s.sessionStore = store.NewSessionStore(store.SessionStoreConfig{ AgentsDir: agentsDir, SessionsDir: sessionsDir, Log: s.log, Sink: s.sink, Strict: s.strict, Yolo: s.yolo, SaveTranscript: func(data []byte) error { name := time.Now().Format("20060102T150405") + "-chat.md" return storeWrite(s.transcriptStore, name, data) }, OnRename: func(oldID, newID string) { s.RemoveGroup("agent", oldID) s.AddGroup("agent", newID) oldPrefix := "/s/" + oldID newPrefix := "/s/" + newID for _, c := range s.conns { c.mu.Lock() for _, f := range c.fids { if f.path == oldPrefix || strings.HasPrefix(f.path, oldPrefix+"/") { f.path = newPrefix + f.path[len(oldPrefix):] f.qid.Path = qidPath(f.path) } } c.mu.Unlock() } }, }) return s } // AddGroup adds a user to a group. func (s *Server) AddGroup(group, user string) { s.mu.Lock() if s.groups[group] == nil { s.groups[group] = make(map[string]bool) } s.groups[group][user] = true s.mu.Unlock() } // RemoveGroup removes a user from a group. func (s *Server) RemoveGroup(group, user string) { s.mu.Lock() if m := s.groups[group]; m != nil { delete(m, user) if len(m) == 0 { delete(s.groups, group) } } s.mu.Unlock() } // InGroup returns true if user is a member of group. func (s *Server) InGroup(group, user string) bool { s.mu.RLock() defer s.mu.RUnlock() return s.groups[group][user] } // fileOwnerGroup returns the uid and gid for a given path. // Session files are owned by the session's principal with group "agent". // Everything else is owned by "ollie" with group "ollie". func (s *Server) fileOwnerGroup(path string) (uid, gid string) { if strings.HasPrefix(path, "/s/") { parts := strings.SplitN(strings.TrimPrefix(path, "/s/"), "/", 2) if len(parts) >= 1 && parts[0] != "new" { if sess := s.sessionStore.Session(parts[0]); sess != nil { return sess.Uname(), "agent" } } } return "ollie", "ollie" } // checkPerm verifies that uname has the requested access (mode) to path. // mode is the 9P open mode: OREAD=0, OWRITE=1, ORDWR=2, OEXEC=3. // Only enforced on session files (/s/{session_id}/...). func (s *Server) checkPerm(uname, path string, mode uint8) error { uid, gid := s.fileOwnerGroup(path) if uid == "ollie" { return nil // non-session files: no enforcement } dir := s.makeStat(path) perm := uint32(dir.Mode) & 0777 // Determine which permission bits apply. var bits uint32 if uname == uid { bits = (perm >> 6) & 7 } else if s.InGroup(gid, uname) { bits = (perm >> 3) & 7 } else { bits = perm & 7 } // Map 9P open mode to required permission bit. omode := mode & 3 switch omode { case plan9.OREAD: if bits&4 == 0 { return fmt.Errorf("permission denied") } case plan9.OWRITE: if bits&2 == 0 { return fmt.Errorf("permission denied") } case plan9.ORDWR: if bits&6 != 6 { return fmt.Errorf("permission denied") } case plan9.OEXEC: if bits&1 == 0 { return fmt.Errorf("permission denied") } } return nil } // defaultTranscriptDir returns the transcript directory from OLLIE_TRANSCRIPT_PATH or the default. func defaultTranscriptDir() string { if p := os.Getenv("OLLIE_TRANSCRIPT_PATH"); p != "" { return p } return paths.CfgDir() + "/transcript" } // defaultMemDir returns the memory directory from OLLIE_MEMORY_PATH or the default. func defaultMemDir() string { if p := os.Getenv("OLLIE_MEMORY_PATH"); p != "" { return p } return paths.CfgDir() + "/memory" } // defaultTmpDir returns the tmp directory from OLLIE_TMP_PATH or the default. func defaultTmpDir() string { if p := os.Getenv("OLLIE_TMP_PATH"); p != "" { return p } return paths.DataDir() + "/tmp" } // sessionFileStore returns a Store for the given session ID. func (s *Server) sessionFileStore(sessID string) (store.RunnableStore, bool) { st, err := s.sessionStore.OpenStore(sessID) if err != nil { return nil, false } return st, true } // sessionAllowTools returns the allowTools set for a session, or nil if unrestricted. func (s *Server) sessionAllowTools(sessID string) map[string]bool { sess := s.sessionStore.Session(sessID) if sess == nil || len(sess.AllowTools) == 0 { return nil } m := make(map[string]bool, len(sess.AllowTools)) for _, t := range sess.AllowTools { m[t] = true } return m } // filterToolIndex filters a tool index (## name\n...\n\n sections) to only include allowed tools. func filterToolIndex(data []byte, allowed map[string]bool) []byte { var out []byte for _, section := range strings.Split(string(data), "## ") { if section == "" { continue } name, _, _ := strings.Cut(section, "\n") if allowed[name] { out = append(out, "## "...) out = append(out, section...) } } return out } // storeRead opens an entry in a store and reads it. func storeRead(s store.Store, name string) ([]byte, error) { e, err := s.Open(name) if err != nil { return nil, err } return e.Read() } // storeWrite opens an entry in a store and writes to it. func storeWrite(s store.Store, name string, data []byte) error { e, err := s.Open(name) if err != nil { return err } return e.Write(data) } // readTimeout is the server-side deadline for all non-blocking store reads. const readTimeout = 10 * time.Second // storeReadCtx is like storeRead but aborts if the context is cancelled or // the read takes longer than readTimeout. func storeReadCtx(ctx context.Context, s store.Store, name string) ([]byte, error) { type result struct { data []byte err error } ch := make(chan result, 1) go func() { data, err := storeRead(s, name) ch <- result{data, err} }() timer := time.NewTimer(readTimeout) defer timer.Stop() select { case r := <-ch: return r.data, r.err case <-ctx.Done(): return nil, ctx.Err() case <-timer.C: return nil, fmt.Errorf("read timeout") } } // storeBlockingRead opens an entry and performs a blocking read. func storeBlockingRead(s store.Store, name string, ctx context.Context, base string) (content []byte, nextBase string, err error) { e, err := s.Open(name) if err != nil { return nil, "", err } return e.BlockingRead(ctx, base) } // Serve handles a single 9P connection. Each request is dispatched to its own // goroutine so blocking reads (e.g. *wait files) do not stall the serve loop. func (s *Server) Serve(conn net.Conn) { defer conn.Close() connCtx, connCancel := context.WithCancel(context.Background()) cs := &connState{ fids: make(map[uint32]*fid), ctx: connCtx, cancel: connCancel, pending: make(map[uint16]context.CancelFunc), } s.mu.Lock() s.conns = append(s.conns, cs) s.mu.Unlock() defer func() { connCancel() s.mu.Lock() for i, c := range s.conns { if c == cs { s.conns = append(s.conns[:i], s.conns[i+1:]...) break } } s.mu.Unlock() }() responses := make(chan *plan9.Fcall, 16) var wg sync.WaitGroup // writer: serialises responses back onto the connection. go func() { for resp := range responses { plan9.WriteFcall(conn, resp) //nolint:errcheck } }() for { fc, err := plan9.ReadFcall(conn) if err != nil { if err != io.EOF { s.log.Error("read: %v", err) } break } reqCtx, reqCancel := context.WithCancel(connCtx) cs.mu.Lock() cs.pending[fc.Tag] = reqCancel cs.mu.Unlock() wg.Add(1) go func(fc *plan9.Fcall, ctx context.Context) { defer func() { reqCancel() cs.mu.Lock() delete(cs.pending, fc.Tag) cs.mu.Unlock() wg.Done() }() responses <- s.handle(cs, fc, ctx) }(fc, reqCtx) } wg.Wait() close(responses) } func (s *Server) handle(cs *connState, fc *plan9.Fcall, ctx context.Context) *plan9.Fcall { switch fc.Type { case plan9.Tversion: msize := fc.Msize if msize > 65536 { msize = 65536 } s.log.Debug("Tversion msize=%d", msize) return &plan9.Fcall{Type: plan9.Rversion, Tag: fc.Tag, Msize: msize, Version: "9P2000"} case plan9.Tauth: return errFcall(fc, "no auth required") case plan9.Tattach: s.log.Debug("Tattach fid=%d", fc.Fid) return s.attach(cs, fc) case plan9.Twalk: return s.walk(cs, fc) case plan9.Topen: return s.open(cs, fc) case plan9.Tcreate: return s.create(cs, fc) case plan9.Tread: return s.read(cs, fc, ctx) case plan9.Twrite: return s.write(cs, fc) case plan9.Tstat: return s.stat(cs, fc) case plan9.Twstat: return s.wstat(cs, fc) case plan9.Tflush: cs.mu.Lock() if cancel, ok := cs.pending[fc.Oldtag]; ok { cancel() } cs.mu.Unlock() return &plan9.Fcall{Type: plan9.Rflush, Tag: fc.Tag} case plan9.Tclunk: return s.clunk(cs, fc) case plan9.Tremove: return s.remove(cs, fc) default: return errFcall(fc, "unsupported operation") } } // isSessionStoreFile reports whether path is a fixed file directly under /s/ // (i.e. /s/ where name is in sessionStoreFiles). func isSessionStoreFile(path string) bool { name, ok := strings.CutPrefix(path, "/s/") if !ok || strings.Contains(name, "/") { return false } _, ok = store.SessionStoreFileMode(name) return ok } func errFcall(fc *plan9.Fcall, msg string) *plan9.Fcall { return &plan9.Fcall{Type: plan9.Rerror, Tag: fc.Tag, Ename: msg} } // qidPath returns a stable numeric path for use in Qid structs. func qidPath(path string) uint64 { if path == "/" { return 0 } h := fnv.New64a() h.Write([]byte(path)) return h.Sum64() } // pathType returns "dir", "file", or "" (not found) for a logical path. func (s *Server) pathType(path string) string { if path == "/" { return "dir" } trimmed := strings.TrimPrefix(path, "/") parts := strings.SplitN(trimmed, "/", 3) switch { case len(parts) == 1 && parts[0] == "s": return "dir" case len(parts) == 2 && parts[0] == "s": if info, err := s.sessionStore.Stat(parts[1]); err == nil { if info.IsDir() { return "dir" } return "file" } case len(parts) == 1 && parts[0] == "a": return "dir" case len(parts) == 1 && parts[0] == "p": return "dir" case len(parts) == 1 && parts[0] == "m": return "dir" case len(parts) == 1 && parts[0] == "sk": return "dir" case len(parts) == 1 && parts[0] == "u": return "dir" case len(parts) == 2 && parts[0] == "u": if _, err := s.utilStore.Stat(parts[1]); err == nil { return "file" } case len(parts) == 1 && parts[0] == "x": return "dir" case len(parts) == 2 && parts[0] == "x": if _, err := s.pluginStore.Stat(parts[1]); err == nil { return "file" } case len(parts) == 1 && parts[0] == "tr": return "dir" case len(parts) == 2 && parts[0] == "tr": if _, err := s.transcriptStore.Stat(parts[1]); err == nil { return "file" } case len(parts) == 1 && parts[0] == "tmp": return "dir" case len(parts) == 2 && parts[0] == "tmp": if _, err := s.tmpStore.Stat(parts[1]); err == nil { return "file" } case len(parts) == 1 && parts[0] == "backends": return "file" case len(parts) == 1 && parts[0] == "help": return "file" case len(parts) == 2 && parts[0] == "a": if _, err := s.agentStore.Stat(parts[1]); err == nil { return "file" } case len(parts) == 2 && parts[0] == "p": if _, err := s.promptStore.Stat(parts[1]); err == nil { return "file" } case len(parts) == 2 && parts[0] == "m": if _, err := s.memStore.Stat(parts[1]); err == nil { return "file" } case len(parts) == 2 && parts[0] == "sk": if _, err := s.skillStore.Stat(parts[1]); err == nil { return "file" } case len(parts) == 3 && parts[0] == "s": if parts[2] == "t" { // /s/{sid}/t is the tools directory if s.sessionStore.Session(parts[1]) != nil { return "dir" } } else if strings.HasPrefix(parts[2], "t/") { // /s/{sid}/t/{rest} — tool file or subdir rel := strings.TrimPrefix(parts[2], "t/") if info, err := s.toolStore.Stat(rel); err == nil { if info.IsDir() { return "dir" } return "file" } } else if sfs, ok := s.sessionFileStore(parts[1]); ok { if _, err := sfs.Stat(parts[2]); err == nil { return "file" } } } return "" } func pathParent(path string) string { if path == "/" { return "/" } i := strings.LastIndex(path, "/") if i == 0 { return "/" } return path[:i] } func pathJoin(parent, name string) string { if parent == "/" { return "/" + name } return parent + "/" + name } func pathBase(path string) string { i := strings.LastIndex(path, "/") if i < 0 { return path } return path[i+1:] } func boolToDir(isDir bool) uint32 { if isDir { return uint32(plan9.DMDIR) } return 0 } func (s *Server) attach(cs *connState, fc *plan9.Fcall) *plan9.Fcall { cs.mu.Lock() defer cs.mu.Unlock() cs.uname = fc.Uname 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. if fc.Uname != "" { if s.sessionStore.SessionByUname(fc.Uname) != nil { s.AddGroup("agent", fc.Uname) } else { s.AddGroup("user", fc.Uname) } } return &plan9.Fcall{Type: plan9.Rattach, Tag: fc.Tag, Qid: qid} } func (s *Server) walk(cs *connState, fc *plan9.Fcall) *plan9.Fcall { cs.mu.Lock() defer cs.mu.Unlock() f, ok := cs.fids[fc.Fid] if !ok { return errFcall(fc, "bad fid") } // Clone: copy the fid to newfid (even when wnames is empty). newf := &fid{path: f.path, qid: f.qid} if len(fc.Wname) == 0 { cs.fids[fc.Newfid] = newf return &plan9.Fcall{Type: plan9.Rwalk, Tag: fc.Tag, Wqid: []plan9.Qid{}} } s.log.Debug("Twalk fid=%d newfid=%d from=%q wnames=%v", fc.Fid, fc.Newfid, f.path, fc.Wname) wqids := make([]plan9.Qid, 0, len(fc.Wname)) cur := f.path for _, name := range fc.Wname { var next string if name == ".." { next = pathParent(cur) } else { next = pathJoin(cur, name) } t := s.pathType(next) if t == "" { if len(wqids) == 0 { return errFcall(fc, name+": file not found") } break } q := plan9.Qid{Path: qidPath(next)} if t == "dir" { q.Type = QTDir } wqids = append(wqids, q) cur = next } if len(wqids) == len(fc.Wname) { newf.path = cur newf.qid = wqids[len(wqids)-1] cs.fids[fc.Newfid] = newf } return &plan9.Fcall{Type: plan9.Rwalk, Tag: fc.Tag, Wqid: wqids} } func (s *Server) open(cs *connState, fc *plan9.Fcall) *plan9.Fcall { cs.mu.Lock() defer cs.mu.Unlock() f, ok := cs.fids[fc.Fid] if !ok { return errFcall(fc, "bad fid") } if err := s.checkPerm(cs.uname, f.path, fc.Mode); err != nil { return errFcall(fc, err.Error()) } f.mode = fc.Mode s.log.Debug("Topen fid=%d path=%q mode=%d", fc.Fid, f.path, fc.Mode) return &plan9.Fcall{Type: plan9.Ropen, Tag: fc.Tag, Qid: f.qid} } func (s *Server) create(cs *connState, fc *plan9.Fcall) *plan9.Fcall { cs.mu.Lock() defer cs.mu.Unlock() f, ok := cs.fids[fc.Fid] if !ok { return errFcall(fc, "bad fid") } if fc.Perm&plan9.DMDIR != 0 { // mkdir under session tool directory: /s/{sid}/t or /s/{sid}/t/... if strings.HasPrefix(f.path, "/s/") { parts := strings.SplitN(strings.TrimPrefix(f.path, "/s/"), "/", 3) if (len(parts) == 2 && parts[1] == "t") || (len(parts) == 3 && parts[1] == "t") { var rel string if len(parts) == 2 { rel = fc.Name } else { rel = parts[2] + "/" + fc.Name } dir := filepath.Join(s.toolStore.dir, rel) if err := os.MkdirAll(dir, 0755); err != nil { return errFcall(fc, err.Error()) } newPath := pathJoin(f.path, fc.Name) qid := plan9.Qid{Type: QTDir, Path: qidPath(newPath)} f.path = newPath f.qid = qid f.mode = fc.Mode return &plan9.Fcall{Type: plan9.Rcreate, Tag: fc.Tag, Qid: qid} } } return errFcall(fc, "mkdir not supported") } newPath := pathJoin(f.path, fc.Name) s.log.Debug("Tcreate parent=%q name=%q", f.path, fc.Name) // (e.g. touch) produces a real file. switch f.path { case "/a": if err := s.agentStore.Create(fc.Name); err != nil { return errFcall(fc, err.Error()) } case "/m": if err := s.memStore.Create(fc.Name); err != nil { return errFcall(fc, err.Error()) } case "/tmp": if err := s.tmpStore.Create(fc.Name); err != nil { return errFcall(fc, err.Error()) } case "/sk": if err := s.skillStore.Create(fc.Name); err != nil { return errFcall(fc, err.Error()) } default: // Tool creates under session: /s/{sid}/t or /s/{sid}/t/... if strings.HasPrefix(f.path, "/s/") { parts := strings.SplitN(strings.TrimPrefix(f.path, "/s/"), "/", 3) if (len(parts) == 2 && parts[1] == "t") || (len(parts) == 3 && parts[1] == "t") { var rel string if len(parts) == 2 { rel = fc.Name } else { rel = parts[2] + "/" + fc.Name } if err := s.toolStore.Create(rel); err != nil { return errFcall(fc, err.Error()) } } } } qid := plan9.Qid{Path: qidPath(newPath)} f.path = newPath f.qid = qid f.mode = fc.Mode return &plan9.Fcall{Type: plan9.Rcreate, Tag: fc.Tag, Qid: qid} } func (s *Server) read(cs *connState, fc *plan9.Fcall, ctx context.Context) *plan9.Fcall { cs.mu.RLock() f, ok := cs.fids[fc.Fid] if !ok { cs.mu.RUnlock() return errFcall(fc, "bad fid") } path := f.path isDir := f.qid.Type&QTDir != 0 cs.mu.RUnlock() if isDir { s.log.Debug("Tread dir path=%q offset=%d count=%d", path, fc.Offset, fc.Count) data := s.readDir(path, fc.Offset, fc.Count) s.log.Debug("Rread dir path=%q len=%d", path, len(data)) return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data} } // Fixed files directly under /s/ are served from the session store. if isSessionStoreFile(path) { s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) content, err := storeReadCtx(ctx, s.sessionStore, pathBase(path)) if err != nil { s.log.Debug("Rread path=%q err=%v", path, err) return errFcall(fc, err.Error()) } s.log.Debug("Rread path=%q content_len=%d", path, len(content)) return s.readSlice(fc, content) } // backends is a static list of ollie-provided backends. if path == "/backends" { s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) content := []byte(strings.Join(backend.Backends(), "\n") + "\n") return s.readSlice(fc, content) } // help is served from ~/.config/ollie/help.md. if path == "/help" { s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) content, err := os.ReadFile(s.helpPath()) if err != nil { return errFcall(fc, err.Error()) } return s.readSlice(fc, content) } // Agent config files are served from the agent store. if strings.HasPrefix(path, "/a/") { s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) content, err := storeReadCtx(ctx, s.agentStore, pathBase(path)) if err != nil { return errFcall(fc, err.Error()) } return s.readSlice(fc, content) } // Prompt files are served from the prompt store. if strings.HasPrefix(path, "/p/") { s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) content, err := storeReadCtx(ctx, s.promptStore, pathBase(path)) if err != nil { return errFcall(fc, err.Error()) } return s.readSlice(fc, content) } // Memory files are served from the memory store. if strings.HasPrefix(path, "/m/") { s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) content, err := storeReadCtx(ctx, s.memStore, pathBase(path)) if err != nil { return errFcall(fc, err.Error()) } return s.readSlice(fc, content) } // Skill files are served from the skill store. if strings.HasPrefix(path, "/sk/") { s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) content, err := storeReadCtx(ctx, s.skillStore, pathBase(path)) if err != nil { return errFcall(fc, err.Error()) } return s.readSlice(fc, content) } // Util files are served from the util store. if strings.HasPrefix(path, "/u/") { s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) content, err := storeReadCtx(ctx, s.utilStore, pathBase(path)) if err != nil { return errFcall(fc, err.Error()) } return s.readSlice(fc, content) } // Plugin files are served from the plugin store. if strings.HasPrefix(path, "/x/") { s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) content, err := storeReadCtx(ctx, s.pluginStore, pathBase(path)) if err != nil { return errFcall(fc, err.Error()) } return s.readSlice(fc, content) } // Tmp files are served from the tmp store. if strings.HasPrefix(path, "/tmp/") { s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) content, err := storeReadCtx(ctx, s.tmpStore, pathBase(path)) if err != nil { return errFcall(fc, err.Error()) } return s.readSlice(fc, content) } // Transcript files are served from the transcript store. if strings.HasPrefix(path, "/tr/") { s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) content, err := storeReadCtx(ctx, s.transcriptStore, pathBase(path)) if err != nil { return errFcall(fc, err.Error()) } return s.readSlice(fc, content) } // Session files: /s/{id}/{file} if strings.HasPrefix(path, "/s/") { parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3) if len(parts) == 3 { // Tool files under session: /s/{id}/t/{tool} if parts[2] == "t" || strings.HasPrefix(parts[2], "t/") { rel := strings.TrimPrefix(parts[2], "t/") if rel == "" { // reading the directory itself as a file — shouldn't happen return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0} } s.log.Debug("Tread tool path=%q offset=%d count=%d", path, fc.Offset, fc.Count) content, err := storeReadCtx(ctx, s.toolStore, rel) if err != nil { return errFcall(fc, err.Error()) } if rel == "idx" { if allowed := s.sessionAllowTools(parts[1]); allowed != nil { content = filterToolIndex(content, allowed) } } return s.readSlice(fc, content) } s.log.Debug("Tread session file path=%q offset=%d count=%d", path, fc.Offset, fc.Count) sfs, ok := s.sessionFileStore(parts[1]) if !ok { s.log.Debug("Rread session not found: %s", parts[1]) return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0} } // fifo.out: non-zero offset is the trailing EOF read after a successful pop. if parts[2] == "fifo.out" && fc.Offset > 0 { return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0} } // *wait files block until a value changes; use connection context. // One blocking read per open: a non-zero offset means the client // already received data this open and is now polling for EOF. if strings.HasSuffix(parts[2], "wait") { if fc.Offset > 0 { return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0} } cs.mu.RLock() f, fidOK := cs.fids[fc.Fid] var base string if fidOK { base = f.waitBase } cs.mu.RUnlock() // Wrap with a short timeout so the FUSE read returns // periodically, allowing pending signals (e.g. SIGINT) to // be delivered to the blocked client process. waitCtx, waitCancel := context.WithTimeout(ctx, 5*time.Second) defer waitCancel() content, nextBase, err := storeBlockingRead(sfs, parts[2], waitCtx, base) if err != nil { return errFcall(fc, err.Error()) } // Update the fid's baseline for subsequent reads. if nextBase != "" { cs.mu.Lock() if f, ok := cs.fids[fc.Fid]; ok { f.waitBase = nextBase } cs.mu.Unlock() } return s.readSlice(fc, content) } content, err := storeReadCtx(ctx, sfs, parts[2]) if err != nil { s.log.Debug("Rread session file err=%v", err) return errFcall(fc, err.Error()) } s.log.Debug("Rread session file path=%q content_len=%d", path, len(content)) return s.readSlice(fc, content) } } s.log.Debug("Tread unhandled path=%q", path) return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0} } // readSlice serves a byte slice at the requested offset/count. func (s *Server) readSlice(fc *plan9.Fcall, content []byte) *plan9.Fcall { var data []byte off := int(fc.Offset) if off < len(content) { end := off + int(fc.Count) if end > len(content) { end = len(content) } data = content[off:end] } return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data} } func (s *Server) helpPath() string { return paths.CfgDir() + "/help.md" } func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall { cs.mu.Lock() f, ok := cs.fids[fc.Fid] if !ok { cs.mu.Unlock() return errFcall(fc, "bad fid") } s.log.Debug("Twrite fid=%d path=%q offset=%d len=%d", fc.Fid, f.path, fc.Offset, len(fc.Data)) // Accumulate; the 9P client may split large writes across multiple Twrite messages. end := int(fc.Offset) + len(fc.Data) if end > len(f.writeBuf) { grown := make([]byte, end) copy(grown, f.writeBuf) f.writeBuf = grown } copy(f.writeBuf[fc.Offset:], fc.Data) cs.mu.Unlock() return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))} } func (s *Server) stat(cs *connState, fc *plan9.Fcall) *plan9.Fcall { cs.mu.RLock() f, ok := cs.fids[fc.Fid] cs.mu.RUnlock() if !ok { return errFcall(fc, "bad fid") } dir := s.makeStat(f.path) s.log.Debug("Tstat path=%q mode=%o len=%d", f.path, dir.Mode, dir.Length) stat, err := dir.Bytes() if err != nil { return errFcall(fc, err.Error()) } return &plan9.Fcall{Type: plan9.Rstat, Tag: fc.Tag, Stat: stat} } func (s *Server) wstat(cs *connState, fc *plan9.Fcall) *plan9.Fcall { cs.mu.Lock() f, ok := cs.fids[fc.Fid] cs.mu.Unlock() if !ok { return errFcall(fc, "bad fid") } // Parse the new Dir from the stat bytes. newDir, err := plan9.UnmarshalDir(fc.Stat) if err != nil { // Some clients send minimal wstat (e.g. truncate); accept silently. return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag} } oldName := pathBase(f.path) s.log.Debug("Twstat path=%q oldName=%q newName=%q", f.path, oldName, newDir.Name) if newDir.Name == "" || newDir.Name == oldName { return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag} } switch { case strings.HasPrefix(f.path, "/a/"): if err := s.agentStore.Rename(oldName, newDir.Name); err != nil { return errFcall(fc, err.Error()) } cs.mu.Lock() f.path = "/a/" + newDir.Name f.qid.Path = qidPath(f.path) cs.mu.Unlock() case strings.HasPrefix(f.path, "/m/"): if err := s.memStore.Rename(oldName, newDir.Name); err != nil { return errFcall(fc, err.Error()) } cs.mu.Lock() f.path = "/m/" + newDir.Name f.qid.Path = qidPath(f.path) cs.mu.Unlock() case strings.HasPrefix(f.path, "/sk/"): if err := s.skillStore.Rename(oldName, newDir.Name); err != nil { return errFcall(fc, err.Error()) } cs.mu.Lock() f.path = "/sk/" + newDir.Name f.qid.Path = qidPath(f.path) cs.mu.Unlock() case strings.HasPrefix(f.path, "/s/"): parts := strings.SplitN(strings.TrimPrefix(f.path, "/s/"), "/", 3) if len(parts) == 3 && parts[1] == "t" { // Tool rename under session: /s/{sid}/t/{rel} oldRel := parts[2] parentRel := oldRel[:len(oldRel)-len(oldName)] newRel := parentRel + newDir.Name if err := s.toolStore.Rename(oldRel, newRel); err != nil { return errFcall(fc, err.Error()) } cs.mu.Lock() f.path = "/s/" + parts[0] + "/t/" + newRel f.qid.Path = qidPath(f.path) cs.mu.Unlock() } else if len(parts) == 1 && f.path != "/s/new" { // Session directory rename: /s/{old} -> /s/{new} if err := s.sessionStore.Rename(parts[0], newDir.Name); err != nil { return errFcall(fc, err.Error()) } } default: return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag} } return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag} } func (s *Server) clunk(cs *connState, fc *plan9.Fcall) *plan9.Fcall { cs.mu.Lock() f, ok := cs.fids[fc.Fid] var path string var data []byte var writable bool if ok { if m := f.mode & 3; m == plan9.OWRITE || m == plan9.ORDWR { path = f.path writable = true data = make([]byte, len(f.writeBuf)) copy(data, f.writeBuf) } delete(cs.fids, fc.Fid) } uname := cs.uname cs.mu.Unlock() if writable { s.log.Debug("Tclunk flush path=%q writeBuf=%d uname=%q", path, len(data), uname) input := strings.TrimSpace(string(data)) if s.isAsyncWrite(path) { go s.handleWrite(path, input, uname) //nolint:errcheck } else if err := s.handleWrite(path, input, uname); err != nil { s.log.Debug("Tclunk handleWrite err=%v", err) return errFcall(fc, err.Error()) } } else { s.log.Debug("Tclunk fid=%d path=%q (no write)", fc.Fid, path) } return &plan9.Fcall{Type: plan9.Rclunk, Tag: fc.Tag} } // isAsyncWrite returns true for paths where writes may block (agent turns) // and Rerror is not useful. func (s *Server) isAsyncWrite(path string) bool { if !strings.HasPrefix(path, "/s/") { return false } switch pathBase(path) { case "prompt", "fifo.in", "ctl": return true } return false } func (s *Server) remove(cs *connState, fc *plan9.Fcall) *plan9.Fcall { cs.mu.Lock() f, ok := cs.fids[fc.Fid] if ok { delete(cs.fids, fc.Fid) } cs.mu.Unlock() if !ok { return errFcall(fc, "bad fid") } path := f.path s.log.Debug("Tremove path=%q", path) var err error switch { case strings.HasPrefix(path, "/a/"): err = s.agentStore.Delete(pathBase(path)) case strings.HasPrefix(path, "/m/"): err = s.memStore.Delete(pathBase(path)) case strings.HasPrefix(path, "/sk/"): err = s.skillStore.Delete(pathBase(path)) case strings.HasPrefix(path, "/tmp/"): err = s.tmpStore.Delete(pathBase(path)) case strings.HasPrefix(path, "/tr/"): err = s.transcriptStore.Delete(pathBase(path)) case strings.HasPrefix(path, "/s/") && path != "/s/new": parts := strings.SplitN(strings.TrimPrefix(path, "/s/"), "/", 3) if len(parts) >= 2 && (parts[1] == "t" || strings.HasPrefix(parts[1], "t/")) { // Tool remove: /s/{sid}/t/{tool} // Reconstruct: parts[0]=sid, rest starts with t/... // Actually with SplitN(..., 3): parts = [sid, "t", rest] or [sid, "t"] // But we split on /s/ prefix first, so path="/s/sid/t/file" -> "sid/t/file" split into ["sid","t","file"] or ["sid","t/file"] // Wait — SplitN("sid/t/file", "/", 3) = ["sid", "t", "file"] // SplitN("sid/t/sub/file", "/", 3) = ["sid", "t", "sub/file"] if len(parts) == 2 && parts[1] == "t" { err = nil // can't remove the t/ dir itself } else if len(parts) == 3 && parts[1] == "t" { err = s.toolStore.Delete(parts[2]) } else { err = nil } } else if len(parts) >= 2 { // Session file remove err = nil // synthetic file; let rm -r continue } else { err = s.sessionStore.Delete(parts[0]) } default: return errFcall(fc, "remove not supported") } if err != nil { return errFcall(fc, err.Error()) } return &plan9.Fcall{Type: plan9.Rremove, Tag: fc.Tag} } // handleWrite processes a fully-assembled write payload for the given path. // Called synchronously from clunk; prompt writes are the exception (spawned // as a goroutine because they block for the entire agent turn). func (s *Server) handleWrite(path, input, uname string) error { s.log.Debug("handleWrite path=%q input_len=%d uname=%q", path, len(input), uname) if path == "/s/new" { if input == "" { return nil } return storeWrite(s.sessionStore, "new", []byte(input)) } // Agent config writes go to the agent store. if strings.HasPrefix(path, "/a/") { return storeWrite(s.agentStore, pathBase(path), []byte(input)) } // Memory file writes go to the memory store. if strings.HasPrefix(path, "/m/") { return storeWrite(s.memStore, pathBase(path), []byte(input)) } // Skill file writes go to the skill store. if strings.HasPrefix(path, "/sk/") { return storeWrite(s.skillStore, pathBase(path), []byte(input)) } // Tmp file writes go to the tmp store. if strings.HasPrefix(path, "/tmp/") { return storeWrite(s.tmpStore, pathBase(path), []byte(input)) } // Session file writes: /s/{sessid}/{file} if strings.HasPrefix(path, "/s/") { parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3) if len(parts) == 3 { // Tool file writes: /s/{id}/t/{tool} if strings.HasPrefix(parts[2], "t/") { rel := strings.TrimPrefix(parts[2], "t/") return storeWrite(s.toolStore, rel, []byte(input)) } sfs, ok := s.sessionFileStore(parts[1]) if !ok { return fmt.Errorf("session not found: %s", parts[1]) } return storeWrite(sfs, parts[2], []byte(input)) } } return nil } // Shutdown kills all active sessions and batch jobs. func (s *Server) Shutdown() { s.sessionStore.Shutdown() } // InterruptAll cancels any in-progress agent turn on every active session. func (s *Server) InterruptAll() { s.sessionStore.InterruptAll() } // readDir serializes directory entries for the given path, respecting offset and count. func (s *Server) readDir(path string, offset uint64, count uint32) []byte { var dirs []plan9.Dir makeDir := func(name, fpath string, isDir bool, mode plan9.Perm) plan9.Dir { q := plan9.Qid{Path: qidPath(fpath)} if isDir { q.Type = QTDir } return plan9.Dir{Qid: q, Mode: mode, Name: name, Uid: "ollie", Gid: "ollie", Muid: "ollie"} } if path == "/" { dirs = append(dirs, makeDir("a", "/a", true, plan9.DMDIR|0755)) dirs = append(dirs, makeDir("backends", "/backends", false, 0444)) dirs = append(dirs, makeDir("help", "/help", false, 0444)) dirs = append(dirs, makeDir("m", "/m", true, plan9.DMDIR|0755)) dirs = append(dirs, makeDir("p", "/p", true, plan9.DMDIR|0555)) dirs = append(dirs, makeDir("s", "/s", true, plan9.DMDIR|0555)) dirs = append(dirs, makeDir("sk", "/sk", true, plan9.DMDIR|0555)) dirs = append(dirs, makeDir("tmp", "/tmp", true, plan9.DMDIR|0755)) dirs = append(dirs, makeDir("u", "/u", true, plan9.DMDIR|0755)) dirs = append(dirs, makeDir("x", "/x", true, plan9.DMDIR|0555)) dirs = append(dirs, makeDir("tr", "/tr", true, plan9.DMDIR|0555)) } else if path == "/a" { entries, _ := s.agentStore.List() for _, e := range entries { if !e.IsDir() { dirs = append(dirs, makeDir(e.Name(), "/a/"+e.Name(), false, 0666)) } } } else if path == "/p" { entries, _ := s.promptStore.List() for _, e := range entries { if !e.IsDir() { dirs = append(dirs, makeDir(e.Name(), "/p/"+e.Name(), false, 0666)) } } } else if path == "/m" { entries, _ := s.memStore.List() for _, e := range entries { if !e.IsDir() { d := makeDir(e.Name(), "/m/"+e.Name(), false, 0666) if info, err := e.Info(); err == nil { d.Atime = uint32(info.ModTime().Unix()) d.Mtime = uint32(info.ModTime().Unix()) } dirs = append(dirs, d) } } } else if path == "/sk" { entries, _ := s.skillStore.List() for _, e := range entries { mode := plan9.Perm(0666) if e.Name() == "idx" { mode = 0444 } dirs = append(dirs, makeDir(e.Name(), "/sk/"+e.Name(), false, mode)) } } else if path == "/tmp" { entries, _ := s.tmpStore.List() for _, e := range entries { if !e.IsDir() { d := makeDir(e.Name(), "/tmp/"+e.Name(), false, 0600) if info, err := e.Info(); err == nil { d.Atime = uint32(info.ModTime().Unix()) d.Mtime = uint32(info.ModTime().Unix()) } dirs = append(dirs, d) } } } else if path == "/tr" { entries, _ := s.transcriptStore.List() for _, e := range entries { if !e.IsDir() { d := makeDir(e.Name(), "/tr/"+e.Name(), false, 0444) if info, err := e.Info(); err == nil { d.Atime = uint32(info.ModTime().Unix()) d.Mtime = uint32(info.ModTime().Unix()) } dirs = append(dirs, d) } } } else if path == "/u" { entries, _ := s.utilStore.List() for _, e := range entries { dirs = append(dirs, makeDir(e.Name(), "/u/"+e.Name(), false, 0555)) } } else if path == "/x" { entries, _ := s.pluginStore.List() for _, e := range entries { dirs = append(dirs, makeDir(e.Name(), "/x/"+e.Name(), false, 0555)) } } else if path == "/s" { entries, _ := s.sessionStore.List() for _, e := range entries { info, _ := e.Info() perm := plan9.Perm(info.Mode() & 0777) if e.IsDir() { perm = plan9.DMDIR | perm } dirs = append(dirs, makeDir(e.Name(), "/s/"+e.Name(), e.IsDir(), perm)) } } else if strings.HasPrefix(path, "/s/") { // /s/{sid}/t — list tools // /s/{sid}/t/{subdir} — list tool subdirectory // /s/{sid} — list session files + t/ parts := strings.SplitN(strings.TrimPrefix(path, "/s/"), "/", 3) if len(parts) >= 2 && parts[1] == "t" { if len(parts) == 2 { // /s/{sid}/t entries, _ := s.toolStore.List() allowed := s.sessionAllowTools(parts[0]) for _, e := range entries { if e.Name() == "idx" { dirs = append(dirs, makeDir(e.Name(), path+"/"+e.Name(), false, 0444)) continue } if allowed != nil && !allowed[e.Name()] { continue } mode := plan9.Perm(0777) dirs = append(dirs, makeDir(e.Name(), path+"/"+e.Name(), e.IsDir(), mode|plan9.Perm(boolToDir(e.IsDir())))) } } else { // /s/{sid}/t/{subdir...} rel := parts[2] entries, _ := s.toolStore.ListDir(rel) for _, e := range entries { dirs = append(dirs, makeDir(e.Name(), path+"/"+e.Name(), e.IsDir(), 0777|plan9.Perm(boolToDir(e.IsDir())))) } } } else if len(parts) == 1 { // /s/{sid} — session files + t/ sessID := parts[0] if sfs, ok := s.sessionFileStore(sessID); ok { entries, _ := sfs.List() for _, e := range entries { info, _ := e.Info() dirs = append(dirs, makeDir(e.Name(), path+"/"+e.Name(), false, plan9.Perm(info.Mode()))) } } dirs = append(dirs, makeDir("t", path+"/t", true, plan9.DMDIR|0777)) } } // Serialize all entries to a byte slice. var allData []byte for _, d := range dirs { b, err := d.Bytes() if err != nil { continue } allData = append(allData, b...) } if offset >= uint64(len(allData)) { return nil } // Return complete entries starting at offset, up to count bytes. remaining := allData[offset:] var result []byte for len(remaining) >= 2 { // Each serialized Dir starts with a uint16 (little-endian) size of the rest. entrySize := int(remaining[0]) | int(remaining[1])<<8 total := entrySize + 2 if total > len(remaining) { break } if uint32(len(result)+total) > count { break } result = append(result, remaining[:total]...) remaining = remaining[total:] } return result } // makeStat builds a plan9.Dir for a logical path. func (s *Server) makeStat(path string) plan9.Dir { base := pathBase(path) if path == "/" { base = "." } t := s.pathType(path) isDir := t == "dir" qid := plan9.Qid{Path: qidPath(path)} 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 | 0777 } else { mode = plan9.DMDIR | 0555 } } else if path == "/a" || path == "/m" || path == "/tmp" || path == "/u" { mode = plan9.DMDIR | 0755 } else { mode = plan9.DMDIR | 0555 } } else { switch base { case "ctl": mode = 0222 // owner+group+other write case "prompt", "fifo.in": mode = 0022 // group+other write; owner cannot write (no self-prompt) case "chat", "usage", "cost", "ctxsz", "models", "fifo.out", "offset", "statewait", "systemprompt": mode = 0444 case "cfg": mode = 0666 case "tail": mode = 0555 default: if path == "/backends" || path == "/help" { mode = 0444 } else if isSessionStoreFile(path) { mode_, _ := store.SessionStoreFileMode(base) mode = plan9.Perm(mode_) } else if strings.HasPrefix(path, "/a/") { mode = 0666 } else if strings.HasPrefix(path, "/p/") { mode = 0444 } else if strings.HasPrefix(path, "/m/") { mode = 0666 } else if path == "/sk/idx" { mode = 0444 } else if strings.HasPrefix(path, "/sk/") { mode = 0666 } else if strings.HasPrefix(path, "/s/") { parts := strings.SplitN(strings.TrimPrefix(path, "/s/"), "/", 3) if len(parts) == 3 && parts[1] == "t" && parts[2] == "idx" { mode = 0444 } else if len(parts) == 3 && parts[1] == "t" { mode = 0777 } else { mode = 0444 } } else if strings.HasPrefix(path, "/u/") { mode = 0555 } else if strings.HasPrefix(path, "/x/") { mode = 0555 } else { mode = 0444 } } } uid, gid := s.fileOwnerGroup(path) dir := plan9.Dir{ Qid: qid, Mode: mode, Name: base, Uid: uid, Gid: gid, Muid: uid, } // 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/") { parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3) if len(parts) == 3 && parts[0] == "s" { if sess := s.sessionStore.Session(parts[1]); sess != nil { if base == "chat" { length, vers := sess.LogInfo() dir.Length = uint64(length) dir.Qid.Vers = vers } } } } // For memory and plan files, report real size and timestamps from the store. if strings.HasPrefix(path, "/m/") { if info, err := s.memStore.Stat(base); err == nil { dir.Length = uint64(info.Size()) dir.Atime = uint32(info.ModTime().Unix()) dir.Mtime = uint32(info.ModTime().Unix()) } } // For all other readable files, compute content length so clients // that check stat before reading (cat, 9pfuse, etc.) see non-zero size. if dir.Length == 0 && !isDir { switch { case isSessionStoreFile(path): if content, err := storeRead(s.sessionStore, base); err == nil { dir.Length = uint64(len(content)) } case path == "/backends": dir.Length = uint64(len(strings.Join(backend.Backends(), "\n") + "\n")) case path == "/help": if info, err := os.Stat(s.helpPath()); err == nil { dir.Length = uint64(info.Size()) } case strings.HasPrefix(path, "/a/"): if info, err := s.agentStore.Stat(base); err == nil { dir.Length = uint64(info.Size()) } case strings.HasPrefix(path, "/p/"): if info, err := s.promptStore.Stat(base); err == nil { dir.Length = uint64(info.Size()) } case strings.HasPrefix(path, "/sk/"): if content, err := storeRead(s.skillStore, 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) if len(parts) == 3 && parts[1] == "t" { if content, err := storeRead(s.toolStore, parts[2]); err == nil { dir.Length = uint64(len(content)) } } case strings.HasPrefix(path, "/u/"): if content, err := storeRead(s.utilStore, base); err == nil { dir.Length = uint64(len(content)) } case strings.HasPrefix(path, "/x/"): if content, err := storeRead(s.pluginStore, base); err == nil { dir.Length = uint64(len(content)) } case strings.HasPrefix(path, "/tmp/"): 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/"): 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/"): parts := strings.SplitN(strings.TrimPrefix(path, "/s/"), "/", 2) if len(parts) == 2 { if sfs, ok := s.sessionFileStore(parts[0]); ok { if info, err := sfs.Stat(parts[1]); err == nil { dir.Length = uint64(info.Size()) } } } } } return dir }