// 9P filesystem server for ollie sessions. // See doc comment below for filesystem layout. package main import ( "context" "fmt" "hash/fnv" "io" "net" "os" "os/user" "strings" "sync" "time" "ollie/pkg/agent" "ollie/pkg/backend" olog "ollie/pkg/log" "ollie/pkg/paths" "ollie/pkg/tools/execute" "olliesrv/session" "olliesrv/store" "9fans.net/go/plan9" ) // Re-export store types for use in server.go. type ( StoreEntry = store.StoreEntry Store = store.Store syntheticFileInfo = store.SyntheticFileInfo ) 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 *store.DirStore utilStore Store pluginStore Store skillStore Store sessionMgr *session.Manager transcriptStore Store tmpStore Store rootStore *store.DirStore 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.NewDirStore(agentsDirs, 0644), promptStore: store.NewDirStore(agent.PromptsDirs(), 0444, store.WithReadOnly()), memStore: store.NewDirStore([]string{memDir}, 0644), toolStore: store.NewDirStore([]string{execute.ToolsPath()}, 0755, store.WithIndex(store.ToolIndex)), utilStore: store.NewDirStore([]string{paths.CfgDir() + "/scripts/u"}, 0555, store.WithReadOnly()), pluginStore: store.NewDirStore([]string{execute.PluginsPath()}, 0555, store.WithReadOnly()), skillStore: store.NewSkillStore(), transcriptStore: store.NewDirStore([]string{transcriptDir}, 0444, store.WithReadOnly()), tmpStore: store.NewDirStore([]string{tmpDir}, 0600), rootStore: store.NewRootStore(), groups: make(map[string]map[string]bool), } for _, o := range opts { o(s) } s.sessionMgr = session.NewManager(session.ManagerConfig{ AgentsDir: agentsDir, SessionsDir: sessionsDir, Log: s.log, Sink: s.sink, Strict: s.strict, Yolo: s.yolo, ToolStore: s.toolStore, 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 s.mu.RLock() conns := s.conns s.mu.RUnlock() for _, c := range 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 } // storeRoute maps a path prefix to its backing store. type storeRoute struct { prefix string store func() Store } // storeRoutes returns the route table for generic store-backed paths. func (s *Server) storeRoutes() []storeRoute { return []storeRoute{ {"/a/", func() Store { return s.agentStore }}, {"/p/", func() Store { return s.promptStore }}, {"/m/", func() Store { return s.memStore }}, {"/s/", func() Store { return s.sessionMgr.Store() }}, {"/sk/", func() Store { return s.skillStore }}, {"/u/", func() Store { return s.utilStore }}, {"/x/", func() Store { return s.pluginStore }}, {"/tmp/", func() Store { return s.tmpStore }}, {"/tr/", func() Store { return s.transcriptStore }}, } } // routeStore returns the store and entry name for a path, or nil if no route matches. func (s *Server) routeStore(path string) (Store, string) { for _, r := range s.storeRoutes() { if strings.HasPrefix(path, r.prefix) { return r.store(), strings.TrimPrefix(path, r.prefix) } } // Root-level files (e.g. /backends, /help). name := strings.TrimPrefix(path, "/") if !strings.Contains(name, "/") { if _, err := s.rootStore.Stat(name); err == nil { return s.rootStore, name } } return nil, "" } // openEntry opens a StoreEntry for the given path. func (s *Server) openEntry(path string) (StoreEntry, error) { st, name := s.routeStore(path) if st == nil { return nil, fmt.Errorf("%s: not found", path) } return st.Open(name) } // routeDir returns the store for a directory path (e.g. "/a" → agentStore). func (s *Server) routeDir(path string) Store { for _, r := range s.storeRoutes() { if path+"/" == r.prefix { return r.store() } } return nil } // 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 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 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 "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. // Enforced on all paths. func (s *Server) checkPerm(uname, path string, mode uint8) error { uid, gid := s.fileOwnerGroup(path) 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" } // 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 // 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") } } // 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/") if !ok || strings.Contains(name, "/") { return false } _, ok = session.FileMode(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" } // Check route table: directory match (e.g. "/a") or file match (e.g. "/a/foo"). if st := s.routeDir(path); st != nil { return "dir" } if st, name := s.routeStore(path); st != nil { if info, err := st.Stat(name); err == nil { if info.IsDir() { return "dir" } return "file" } return "" } 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.sessionMgr.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") } newPath := pathJoin(f.path, fc.Name) if fc.Perm&plan9.DMDIR != 0 { // mkdir: delegate to store if it supports MkdirAll. type dirCreator interface { MkdirAll(string) error } st, name := s.routeStore(newPath) if st == nil { return errFcall(fc, "mkdir not supported") } if dc, ok := st.(dirCreator); ok { if err := dc.MkdirAll(name); err != nil { return errFcall(fc, err.Error()) } } else { return errFcall(fc, "mkdir not supported") } 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} } s.log.Debug("Tcreate parent=%q name=%q", f.path, fc.Name) // File create: route to the parent directory's store. if st := s.routeDir(f.path); st != nil { if err := st.Create(fc.Name); err != nil { return errFcall(fc, err.Error()) } } else if st, parentName := s.routeStore(f.path); st != nil { // Parent is a subdirectory within a store; create relative to it. type subCreator interface { Create(string) error } if sc, ok := st.(subCreator); ok { if err := sc.Create(parentName + "/" + fc.Name); 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) return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data} } s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) entry, err := s.openEntry(path) if err != nil { return errFcall(fc, err.Error()) } // OneShot entries yield data once per open; offset>0 means EOF. if entry.OneShot() && fc.Offset > 0 { return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0} } // Blocking entries use BlockingRead with timeout and waitBase tracking. if entry.IsBlocking() { cs.mu.RLock() f, fidOK := cs.fids[fc.Fid] var base string if fidOK { base = f.waitBase } cs.mu.RUnlock() waitCtx, waitCancel := context.WithTimeout(ctx, 5*time.Second) defer waitCancel() content, nextBase, err := entry.BlockingRead(waitCtx, base) if err != nil { return errFcall(fc, err.Error()) } if nextBase != "" { cs.mu.Lock() if f, ok := cs.fids[fc.Fid]; ok { f.waitBase = nextBase } cs.mu.Unlock() } return s.readSlice(fc, content) } // Normal read with timeout. type result struct { data []byte err error } ch := make(chan result, 1) go func() { data, err := entry.Read() ch <- result{data, err} }() timer := time.NewTimer(readTimeout) defer timer.Stop() select { case r := <-ch: if r.err != nil { return errFcall(fc, r.err.Error()) } return s.readSlice(fc, r.data) case <-ctx.Done(): return errFcall(fc, ctx.Err().Error()) case <-timer.C: return errFcall(fc, "read timeout") } } // 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} } st, relPath := s.routeStore(f.path) if st == nil { return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag} } if err := st.Rename(relPath, newDir.Name); err != nil { return errFcall(fc, err.Error()) } // Update fid path (for non-session renames; session renames are // handled by the OnRename callback which rewrites all affected fids). for _, r := range s.storeRoutes() { if strings.HasPrefix(f.path, r.prefix) { newPath := r.prefix + relPath[:len(relPath)-len(oldName)] + newDir.Name if newPath != f.path { cs.mu.Lock() f.path = newPath f.qid.Path = qidPath(f.path) cs.mu.Unlock() } break } } 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)) async := false if entry, err := s.openEntry(path); err == nil { async = entry.Async() } if async { 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} } 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) st, name := s.routeStore(path) if st == nil { return errFcall(fc, "remove not supported") } if err := st.Delete(name); 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) st, name := s.routeStore(path) if st == nil { return nil } return storeWrite(st, name, []byte(input)) } // Shutdown kills all active sessions and batch jobs. func (s *Server) Shutdown() { s.sessionMgr.Shutdown() } // InterruptAll cancels any in-progress agent turn on every active session. func (s *Server) InterruptAll() { s.sessionMgr.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 if path == "/" { rootEntries := []string{"a", "backends", "help", "m", "p", "s", "sk", "tmp", "u", "x", "tr"} for _, name := range rootEntries { fpath := "/" + name st := s.makeStat(fpath) dirs = append(dirs, st) } } else if st := s.routeDir(path); st != nil { // Top-level store directory (e.g. /a, /s, /m). entries, _ := st.List() for _, e := range entries { fpath := path + "/" + e.Name() d := s.makeStat(fpath) 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 st, name := s.routeStore(path); st != nil { // Subdirectory within a store (e.g. /s/mysession, /s/mysession/t). type dirLister interface { ListDir(string) ([]os.DirEntry, error) } if dl, ok := st.(dirLister); ok { entries, _ := dl.ListDir(name) for _, e := range entries { fpath := path + "/" + e.Name() d := s.makeStat(fpath) if info, err := e.Info(); err == nil { d.Atime = uint32(info.ModTime().Unix()) d.Mtime = uint32(info.ModTime().Unix()) } dirs = append(dirs, d) } } } // 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 | 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 } } 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 isSessionFile(path) { mode_, _ := session.FileMode(base) mode = plan9.Perm(mode_) } else if strings.HasPrefix(path, "/a/") { mode = 0666 } else if strings.HasPrefix(path, "/p/") { mode = 0664 } else if strings.HasPrefix(path, "/m/") { mode = 0666 } else if path == "/sk/idx" { mode = 0444 } else if strings.HasPrefix(path, "/sk/") { mode = 0664 } 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 = 0400 } else if len(parts) == 3 && parts[1] == "t" { mode = 0500 } 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.sessionMgr.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 isSessionFile(path): if content, err := storeRead(s.sessionMgr.Store(), 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/"): name := strings.TrimPrefix(path, "/s/") if info, err := s.sessionMgr.Store().Stat(name); err == nil { dir.Length = uint64(info.Size()) } } } return dir }