1794 lines
50 KiB
Go
1794 lines
50 KiB
Go
// 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) {
|
|
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 ID 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" {
|
|
// Check if parts[0] is a session (not a script like sh, bfg, etc.)
|
|
if sess := s.sessionStore.Session(parts[0]); sess != nil {
|
|
return parts[0], "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/<name> 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 ID.
|
|
if fc.Uname != "" {
|
|
if sess := s.sessionStore.Session(fc.Uname); sess != 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
|
|
}
|