move t/ into s/{sid}/t/; add allowTools filtering at 9P layer

This commit is contained in:
Levi Neely 2026-05-16 23:55:14 +02:00
parent 393460f767
commit c075964a1f
3 changed files with 213 additions and 120 deletions

View File

@ -63,11 +63,11 @@ ollie/
systemprompt read: fully rendered system prompt for this session
tail exec: exec tail -f chat
usage read: token counts (input, output, requests; [estimated] if not reported by backend)
t/ dir: tool scripts (r/w, backed by ~/.config/ollie/tools/)
idx read: tool index (name, description, args — synthesized at read time)
<script> r/w: tool script content
sk/ dir: skills (r/w, from OLLIE_SKILLS_PATH or ~/.config/ollie/skills/)
<name>.md r/w: skill SKILL.md content
t/ dir: tool scripts (r/w, backed by ~/.config/ollie/tools/)
idx read: tool index (name, description, args — synthesized at read time)
<script> r/w: tool script content
tr/ dir: saved transcripts (read, backed by OLLIE_TRANSCRIPT_PATH or ~/.config/ollie/transcript/)
<timestamp>-chat.md read: transcript saved at session end
tmp/ dir: temporary files (r/w, backed by OLLIE_TMP_PATH or $XDG_DATA_HOME/ollie/tmp/)
@ -90,7 +90,6 @@ Each 9P directory endpoint is backed by a named store implementing the `Store` i
| `/m` | `FlatDirStore` | `Store` |
| `/p` | `FlatDirStore` | `Store` |
| `/sk` | `SkillStore` | `Store` |
| `/t` | `ToolStore` | `Store` |
| `/tr` | `FlatDirStore` | `Store` |
| `/tmp`| `FlatDirStore` | `Store` |
| `/u` | `UtilStore` | `Store` |

314
server.go
View File

@ -327,6 +327,35 @@ func (s *Server) sessionFileStore(sessID string) (store.RunnableStore, bool) {
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)
@ -541,8 +570,6 @@ func (s *Server) pathType(path string) string {
return "dir"
case len(parts) == 1 && parts[0] == "sk":
return "dir"
case len(parts) == 1 && parts[0] == "t":
return "dir"
case len(parts) == 1 && parts[0] == "u":
return "dir"
case len(parts) == 2 && parts[0] == "u":
@ -588,22 +615,22 @@ func (s *Server) pathType(path string) string {
if _, err := s.skillStore.Stat(parts[1]); err == nil {
return "file"
}
case len(parts) == 2 && parts[0] == "t":
if info, err := s.toolStore.Stat(parts[1]); err == nil {
if info.IsDir() {
return "dir"
}
return "file"
}
case len(parts) == 3 && parts[0] == "t":
if info, err := s.toolStore.Stat(parts[1] + "/" + parts[2]); err == nil {
if info.IsDir() {
return "dir"
}
return "file"
}
case len(parts) == 3 && parts[0] == "s":
if sfs, ok := s.sessionFileStore(parts[1]); ok {
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"
}
@ -731,23 +758,27 @@ func (s *Server) create(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
return errFcall(fc, "bad fid")
}
if fc.Perm&plan9.DMDIR != 0 {
if f.path == "/t" || strings.HasPrefix(f.path, "/t/") {
rel := strings.TrimPrefix(f.path, "/t")
if rel == "" {
rel = fc.Name
} else {
rel = rel[1:] + "/" + fc.Name // strip leading /
// 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}
}
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")
}
@ -764,10 +795,6 @@ func (s *Server) create(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
if err := s.memStore.Create(fc.Name); err != nil {
return errFcall(fc, err.Error())
}
case "/t":
if err := s.toolStore.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())
@ -777,10 +804,19 @@ func (s *Server) create(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
return errFcall(fc, err.Error())
}
default:
if strings.HasPrefix(f.path, "/t/") {
rel := strings.TrimPrefix(f.path, "/t/") + "/" + fc.Name
if err := s.toolStore.Create(rel); err != nil {
return errFcall(fc, err.Error())
// 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())
}
}
}
}
@ -880,16 +916,6 @@ func (s *Server) read(cs *connState, fc *plan9.Fcall, ctx context.Context) *plan
return s.readSlice(fc, content)
}
// Tool files are served from the tool store.
if strings.HasPrefix(path, "/t/") {
s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count)
content, err := storeReadCtx(ctx, s.toolStore, strings.TrimPrefix(path, "/t/"))
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)
@ -934,6 +960,25 @@ func (s *Server) read(cs *connState, fc *plan9.Fcall, ctx context.Context) *plan
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 {
@ -1093,26 +1138,25 @@ func (s *Server) wstat(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
f.qid.Path = qidPath(f.path)
cs.mu.Unlock()
case strings.HasPrefix(f.path, "/t/"):
oldRel := strings.TrimPrefix(f.path, "/t/")
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 = "/t/" + newRel
f.qid.Path = qidPath(f.path)
cs.mu.Unlock()
case strings.HasPrefix(f.path, "/s/") && f.path != "/s/new":
// Session directory rename: /s/{old} -> /s/{new}
parts := strings.SplitN(strings.TrimPrefix(f.path, "/"), "/", 3)
if len(parts) != 2 || parts[0] != "s" {
return errFcall(fc, "rename not supported")
}
if err := s.sessionStore.Rename(parts[1], newDir.Name); err != nil {
return errFcall(fc, err.Error())
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:
@ -1187,18 +1231,28 @@ func (s *Server) remove(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
err = s.memStore.Delete(pathBase(path))
case strings.HasPrefix(path, "/sk/"):
err = s.skillStore.Delete(pathBase(path))
case strings.HasPrefix(path, "/t/"):
err = s.toolStore.Delete(strings.TrimPrefix(path, "/t/"))
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":
// If path is /s/{id}/{file}, it's a synthetic session file — no-op so
// that "rm -r s/{id}" can proceed to remove the directory itself, which
// triggers the actual session kill via Delete({id}).
parts := strings.SplitN(strings.TrimPrefix(path, "/s/"), "/", 2)
if len(parts) == 2 {
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])
@ -1240,11 +1294,6 @@ func (s *Server) handleWrite(path, input string) error {
return storeWrite(s.skillStore, pathBase(path), []byte(input))
}
// Tool file writes go to the tool store.
if strings.HasPrefix(path, "/t/") {
return storeWrite(s.toolStore, strings.TrimPrefix(path, "/t/"), []byte(input))
}
// Tmp file writes go to the tmp store.
if strings.HasPrefix(path, "/tmp/") {
return storeWrite(s.tmpStore, pathBase(path), []byte(input))
@ -1254,6 +1303,11 @@ func (s *Server) handleWrite(path, input string) error {
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])
@ -1295,7 +1349,6 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
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("t", "/t", true, plan9.DMDIR|0777))
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))
@ -1335,21 +1388,6 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
}
dirs = append(dirs, makeDir(e.Name(), "/sk/"+e.Name(), false, mode))
}
} else if path == "/t" {
entries, _ := s.toolStore.List()
for _, e := range entries {
mode := plan9.Perm(0777)
if e.Name() == "idx" {
mode = 0444
}
dirs = append(dirs, makeDir(e.Name(), "/t/"+e.Name(), e.IsDir(), mode|plan9.Perm(boolToDir(e.IsDir()))))
}
} else if strings.HasPrefix(path, "/t/") {
rel := strings.TrimPrefix(path, "/t/")
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 path == "/tmp" {
entries, _ := s.tmpStore.List()
for _, e := range entries {
@ -1395,15 +1433,46 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
}
dirs = append(dirs, makeDir(e.Name(), "/s/"+e.Name(), e.IsDir(), perm))
}
} else {
// Session subdirectory: /s/{sessid}
sessID := pathBase(path)
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())))
} 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))
}
}
@ -1454,8 +1523,13 @@ func (s *Server) makeStat(path string) plan9.Dir {
var mode plan9.Perm
if isDir {
qid.Type = QTDir
if path == "/t" || strings.HasPrefix(path, "/t/") {
mode = plan9.DMDIR | 0777
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 {
@ -1487,10 +1561,15 @@ func (s *Server) makeStat(path string) plan9.Dir {
mode = 0444
} else if strings.HasPrefix(path, "/sk/") {
mode = 0666
} else if path == "/t/idx" {
mode = 0444
} else if strings.HasPrefix(path, "/t/") {
mode = 0777
} 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/") {
@ -1561,9 +1640,12 @@ func (s *Server) makeStat(path string) plan9.Dir {
if content, err := storeRead(s.skillStore, base); err == nil {
dir.Length = uint64(len(content))
}
case strings.HasPrefix(path, "/t/"):
if content, err := storeRead(s.toolStore, strings.TrimPrefix(path, "/t/")); 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 {

View File

@ -29,6 +29,7 @@ type Session struct {
ChatOffset int
plan []byte
prevPrompt []byte // last submitted prompt; overwritten on each new submission
AllowTools []string // if non-empty, only these tools are visible/executable
}
func NewSession(id string, core agent.Core, ctx context.Context, cancel context.CancelFunc) *Session {
@ -356,6 +357,7 @@ func (s *SessionStore) createSession(args []string) error {
}
var core agent.Core
var allowTools []string
if s.cfg.NewCore != nil {
var err error
core, err = s.cfg.NewCore(sessID, agentName, cwd)
@ -380,6 +382,9 @@ func (s *SessionStore) createSession(args []string) error {
}
cfg := LoadAgentConfig(s.cfg.AgentsDir, agentName, nil)
if cfg != nil {
allowTools = cfg.AllowTools
}
var execOpts []execute.Option
if s.cfg.Strict {
@ -388,6 +393,12 @@ func (s *SessionStore) createSession(args []string) error {
if s.cfg.Yolo {
execOpts = append(execOpts, execute.WithYolo())
}
if cfg != nil && len(cfg.AllowExecutors) > 0 {
execOpts = append(execOpts, execute.WithAllowExecutors(cfg.AllowExecutors))
}
if cfg != nil && len(cfg.AllowTools) > 0 {
execOpts = append(execOpts, execute.WithAllowTools(cfg.AllowTools))
}
newDisp := tools.NewDispatcherFunc(map[string]func() tools.Server{
"execute": execute.Decl(cwd, execOpts...),
})
@ -409,6 +420,7 @@ func (s *SessionStore) createSession(args []string) error {
ctx, cancel := context.WithCancel(context.Background())
sess := NewSession(sessID, core, ctx, cancel)
sess.AllowTools = allowTools
s.mu.Lock()
s.sessions[sessID] = sess