// Package files exposes the workspace file API consumed by the SPA's file // tree, preview and editor panels: list a directory, search files by name, // read a text file, stream a file's raw bytes, write a file back. // // Access is deliberately unrestricted (absolute paths welcome): ori is meant // to run inside an already-isolated sandbox, and the agent itself has the // same reach. Relative paths resolve against the configured root (the --cwd // given to the agent session). package files import ( "encoding/json" "errors" "io/fs" "net/http" "os" "path/filepath" "sort" "unicode/utf8" ) // maxFileBytes bounds what the read endpoint returns; larger files are // refused rather than truncated, so the editor never silently saves a // partial file back. const maxFileBytes = 5 << 20 // 5 MiB // Service answers the file API requests for one workspace root. type Service struct { root string } // New creates a Service whose relative paths resolve against root. // // Example: // // svc := files.New("/work/my-project") // for pattern, handler := range svc.Routes() { // mux.Handle(pattern, handler) // } func New(root string) *Service { return &Service{root: root} } // Routes returns the API endpoints, keyed by http.ServeMux pattern, ready to // be merged into the server's route table. func (s *Service) Routes() map[string]http.Handler { return map[string]http.Handler{ "GET /api/files": http.HandlerFunc(s.listDirectory), "GET /api/files/search": http.HandlerFunc(s.searchFiles), "GET /api/file": http.HandlerFunc(s.readFile), "PUT /api/file": http.HandlerFunc(s.writeFile), "GET /api/raw": http.HandlerFunc(s.rawFile), } } // Entry describes one child of a listed directory. type Entry struct { // Name is the base name of the entry. Name string `json:"name"` // Path is the resolved path, directly usable in further API calls. Path string `json:"path"` // IsDir tells directories and files apart. IsDir bool `json:"isDir"` // Size is the file size in bytes (0 for directories). Size int64 `json:"size"` } // resolve turns a request path into the path to operate on: empty means the // root, relative paths are joined to it, absolute paths are used as-is. func (s *Service) resolve(path string) string { if path == "" { return s.root } if !filepath.IsAbs(path) { return filepath.Join(s.root, path) } return filepath.Clean(path) } func (s *Service) listDirectory(w http.ResponseWriter, r *http.Request) { dir := s.resolve(r.URL.Query().Get("path")) dirEntries, err := os.ReadDir(dir) if err != nil { writeFSError(w, err) return } entries := make([]Entry, 0, len(dirEntries)) for _, e := range dirEntries { info, infoErr := e.Info() size := int64(0) if infoErr == nil && !e.IsDir() { size = info.Size() } entries = append(entries, Entry{ Name: e.Name(), Path: filepath.Join(dir, e.Name()), IsDir: e.IsDir(), Size: size, }) } // Directories first, then files, each group alphabetically — the order // the file tree renders in. sort.Slice(entries, func(i, j int) bool { if entries[i].IsDir != entries[j].IsDir { return entries[i].IsDir } return entries[i].Name < entries[j].Name }) writeJSON(w, http.StatusOK, map[string]any{"path": dir, "entries": entries}) } func (s *Service) readFile(w http.ResponseWriter, r *http.Request) { path := s.resolve(r.URL.Query().Get("path")) info, err := os.Stat(path) if err != nil { writeFSError(w, err) return } if info.IsDir() { writeError(w, http.StatusBadRequest, "path is a directory") return } if info.Size() > maxFileBytes { writeError(w, http.StatusRequestEntityTooLarge, "file larger than 5 MiB") return } content, err := os.ReadFile(path) if err != nil { writeFSError(w, err) return } if !utf8.Valid(content) { writeError(w, http.StatusUnsupportedMediaType, "not a text file") return } writeJSON(w, http.StatusOK, map[string]any{"path": path, "content": string(content)}) } func (s *Service) writeFile(w http.ResponseWriter, r *http.Request) { path := s.resolve(r.URL.Query().Get("path")) if r.URL.Query().Get("path") == "" { writeError(w, http.StatusBadRequest, "path query parameter is required") return } var body struct { Content string `json:"content"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxFileBytes)).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error()) return } if dir := filepath.Dir(path); dir != "" { if err := os.MkdirAll(dir, 0o755); err != nil { writeFSError(w, err) return } } if err := os.WriteFile(path, []byte(body.Content), 0o644); err != nil { writeFSError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"path": path, "status": "saved"}) } // writeFSError maps filesystem errors to HTTP statuses. func writeFSError(w http.ResponseWriter, err error) { status := http.StatusInternalServerError switch { case errors.Is(err, fs.ErrNotExist): status = http.StatusNotFound case errors.Is(err, fs.ErrPermission): status = http.StatusForbidden } writeError(w, status, err.Error()) } func writeError(w http.ResponseWriter, status int, message string) { writeJSON(w, status, map[string]any{"error": message}) } func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) }