From d837001b5d3016ad8a8674c1c427653d38dda90c Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Sun, 6 Apr 2025 13:21:30 -0400 Subject: [PATCH 1/3] Fix fastapi response to make compatible with open-webui tools use --- servers/filesystem/main.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/servers/filesystem/main.py b/servers/filesystem/main.py index 5644750..cef0f8c 100644 --- a/servers/filesystem/main.py +++ b/servers/filesystem/main.py @@ -29,7 +29,7 @@ app.add_middleware( # Constants ALLOWED_DIRECTORIES = [ - str(pathlib.Path(os.path.expanduser("~/mydir")).resolve()) + str(pathlib.Path(os.path.expanduser("~/")).resolve()) ] # 👈 Replace with your paths # ------------------------------------------------------------------------------ @@ -184,7 +184,7 @@ async def create_directory(data: CreateDirectoryRequest = Body(...)): @app.post( - "/list_directory", response_class=PlainTextResponse, summary="List a directory" + "/list_directory", summary="List a directory" ) async def list_directory(data: ListDirectoryRequest = Body(...)): """ @@ -196,10 +196,11 @@ async def list_directory(data: ListDirectoryRequest = Body(...)): listing = [] for entry in dir_path.iterdir(): - prefix = "[DIR]" if entry.is_dir() else "[FILE]" - listing.append(f"{prefix} {entry.name}") + entry_type = "directory" if entry.is_dir() else "file" + listing.append({"name": entry.name, "type": entry_type}) - return "\n".join(listing) + # Return the list directly, FastAPI will serialize it to JSON + return listing @app.post("/directory_tree", summary="Recursive directory tree") From c0bb3350fb04c5f55701898a0eee8ec5956e0200 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Sun, 6 Apr 2025 13:42:59 -0400 Subject: [PATCH 2/3] Add search_content, delete_path, move_path and get_metadata routes, return available directories on 403s --- servers/filesystem/main.py | 191 ++++++++++++++++++++++++++++++++++++- 1 file changed, 189 insertions(+), 2 deletions(-) diff --git a/servers/filesystem/main.py b/servers/filesystem/main.py index cef0f8c..d1053c3 100644 --- a/servers/filesystem/main.py +++ b/servers/filesystem/main.py @@ -9,7 +9,8 @@ import pathlib import asyncio from typing import List, Optional, Literal import difflib - +import shutil +from datetime import datetime, timezone app = FastAPI( title="Secure Filesystem API", version="0.1.0", @@ -44,7 +45,12 @@ def normalize_path(requested_path: str) -> pathlib.Path: return requested raise HTTPException( status_code=403, - detail=f"Access denied: {requested} is outside allowed directories.", + detail={ + "error": "Access Denied", + "requested_path": str(requested), + "message": "Requested path is outside allowed directories.", + "allowed_directories": ALLOWED_DIRECTORIES, + }, ) @@ -106,6 +112,36 @@ class SearchFilesRequest(BaseModel): ) +class SearchContentRequest(BaseModel): + path: str = Field(..., description="Base directory to search within.") + search_query: str = Field(..., description="Text content to search for (case-insensitive).") + recursive: bool = Field( + default=True, description="Whether to search recursively in subdirectories." + ) + file_pattern: Optional[str] = Field( + default="*", description="Glob pattern to filter files to search within (e.g., '*.py')." + ) + + +class DeletePathRequest(BaseModel): + path: str = Field(..., description="Path to the file or directory to delete.") + recursive: bool = Field( + default=False, description="If true and path is a directory, delete recursively. Required if directory is not empty." + ) + confirm_delete: bool = Field( + ..., description="Must be explicitly set to true to confirm deletion." + ) + + +class MovePathRequest(BaseModel): + source_path: str = Field(..., description="The current path of the file or directory.") + destination_path: str = Field(..., description="The new path for the file or directory.") + + +class GetMetadataRequest(BaseModel): + path: str = Field(..., description="Path to the file or directory to get metadata for.") + + # ------------------------------------------------------------------------------ # Routes # ------------------------------------------------------------------------------ @@ -252,6 +288,157 @@ async def search_files(data: SearchFilesRequest = Body(...)): return {"matches": results or ["No matches found"]} +@app.post("/delete_path", response_class=PlainTextResponse, summary="Delete a file or directory") +async def delete_path(data: DeletePathRequest = Body(...)): + """ + Delete a specified file or directory. Requires explicit confirmation. + Use 'recursive=True' to delete non-empty directories. + """ + if not data.confirm_delete: + raise HTTPException( + status_code=400, + detail="Deletion not confirmed. Set 'confirm_delete' to true to proceed." + ) + + path = normalize_path(data.path) + + try: + if not path.exists(): + raise HTTPException(status_code=404, detail=f"Path not found: {data.path}") + + if path.is_file(): + path.unlink() + return f"Successfully deleted file: {data.path}" + elif path.is_dir(): + if data.recursive: + shutil.rmtree(path) + return f"Successfully deleted directory recursively: {data.path}" + else: + try: + path.rmdir() # Only works for empty directories + return f"Successfully deleted empty directory: {data.path}" + except OSError as e: + # Catch error if directory is not empty and recursive is false + raise HTTPException( + status_code=400, + detail=f"Directory not empty. Use 'recursive=True' to delete non-empty directories. Original error: {e}" + ) + else: + # Should not happen if exists() is true and it's not file/dir, but handle defensively + raise HTTPException(status_code=400, detail=f"Path is not a file or directory: {data.path}") + + except PermissionError: + raise HTTPException(status_code=403, detail=f"Permission denied to delete {data.path}") + except Exception as e: + # Catch other potential errors during deletion + raise HTTPException(status_code=500, detail=f"Failed to delete {data.path}: {e}") + + +@app.post("/move_path", response_class=PlainTextResponse, summary="Move or rename a file or directory") +async def move_path(data: MovePathRequest = Body(...)): + """ + Move or rename a file or directory from source_path to destination_path. + Both paths must be within the allowed directories. + """ + source = normalize_path(data.source_path) + destination = normalize_path(data.destination_path) # Also normalize destination + + try: + if not source.exists(): + raise HTTPException(status_code=404, detail=f"Source path not found: {data.source_path}") + + shutil.move(str(source), str(destination)) + return f"Successfully moved '{data.source_path}' to '{data.destination_path}'" + + except PermissionError: + raise HTTPException(status_code=403, detail=f"Permission denied for move operation involving '{data.source_path}' or '{data.destination_path}'") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to move '{data.source_path}' to '{data.destination_path}': {e}") + + +@app.post("/get_metadata", summary="Get file or directory metadata") +async def get_metadata(data: GetMetadataRequest = Body(...)): + """ + Retrieve metadata for a specified file or directory path. + """ + path = normalize_path(data.path) + + try: + if not path.exists(): + raise HTTPException(status_code=404, detail=f"Path not found: {data.path}") + + stat_result = path.stat() + + # Determine type + if path.is_file(): + file_type = "file" + elif path.is_dir(): + file_type = "directory" + else: + file_type = "other" # Should generally not happen for existing paths normalized + + # Format timestamps (use UTC for consistency) + mod_time = datetime.fromtimestamp(stat_result.st_mtime, tz=timezone.utc).isoformat() + # Creation time (st_birthtime) is macOS/BSD specific, st_ctime is metadata change time on Linux + # Use st_ctime as a fallback if st_birthtime isn't available + try: + create_time = datetime.fromtimestamp(stat_result.st_birthtime, tz=timezone.utc).isoformat() + except AttributeError: + create_time = datetime.fromtimestamp(stat_result.st_ctime, tz=timezone.utc).isoformat() + + + metadata = { + "path": str(path), + "type": file_type, + "size_bytes": stat_result.st_size, + "modification_time_utc": mod_time, + "creation_time_utc": create_time, # Note platform differences in definition + "last_metadata_change_time_utc": datetime.fromtimestamp(stat_result.st_ctime, tz=timezone.utc).isoformat(), + } + return metadata + + except PermissionError: + raise HTTPException(status_code=403, detail=f"Permission denied to access metadata for {data.path}") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to get metadata for {data.path}: {e}") + + +@app.post("/search_content", summary="Search for content within files") +async def search_content(data: SearchContentRequest = Body(...)): + """ + Search for text content within files in a specified directory. + """ + base_path = normalize_path(data.path) + results = [] + search_query_lower = data.search_query.lower() + + if not base_path.is_dir(): + raise HTTPException(status_code=400, detail="Provided path is not a directory") + + iterator = base_path.rglob(data.file_pattern) if data.recursive else base_path.glob(data.file_pattern) + + for item_path in iterator: + if item_path.is_file(): + try: + # Read file line by line to handle potentially large files and different encodings + with item_path.open("r", encoding="utf-8", errors="ignore") as f: + for line_num, line in enumerate(f, 1): + if search_query_lower in line.lower(): + results.append( + { + "file_path": str(item_path), + "line_number": line_num, + "line_content": line.strip(), + } + ) + except Exception as e: + # Log or handle files that cannot be read (e.g., permission errors, binary files) + print(f"Could not read or search file {item_path}: {e}") + continue + + return {"matches": results or ["No matches found"]} + + @app.get("/list_allowed_directories", summary="List access-permitted directories") async def list_allowed_directories(): """ From e4f51122eca85052db4ef060473a6a8a1c43fa00 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Sun, 6 Apr 2025 13:54:11 -0400 Subject: [PATCH 3/3] Fix rest of json issues for all routes, implement SuccessResponse so that read calls are not attempted after move file operations --- servers/filesystem/main.py | 128 +++++++++++++++++++++++++------------ 1 file changed, 86 insertions(+), 42 deletions(-) diff --git a/servers/filesystem/main.py b/servers/filesystem/main.py index d1053c3..d48a755 100644 --- a/servers/filesystem/main.py +++ b/servers/filesystem/main.py @@ -41,7 +41,7 @@ ALLOWED_DIRECTORIES = [ def normalize_path(requested_path: str) -> pathlib.Path: requested = pathlib.Path(os.path.expanduser(requested_path)).resolve() for allowed in ALLOWED_DIRECTORIES: - if str(requested).startswith(allowed): + if str(requested).lower().startswith(allowed.lower()): # Case-insensitive check return requested raise HTTPException( status_code=403, @@ -147,76 +147,118 @@ class GetMetadataRequest(BaseModel): # ------------------------------------------------------------------------------ -@app.post("/read_file", response_class=PlainTextResponse, summary="Read a file") +class SuccessResponse(BaseModel): + message: str = Field(..., description="Success message indicating the operation was completed.") + + +class ReadFileResponse(BaseModel): + content: str = Field(..., description="UTF-8 encoded text content of the file.") + + +class DiffResponse(BaseModel): + diff: str = Field(..., description="Unified diff output comparing original and modified content.") + + +@app.post("/read_file", response_model=ReadFileResponse, summary="Read a file") # Changed response_class to response_model async def read_file(data: ReadFileRequest = Body(...)): """ - Read the entire contents of a file. + Read the entire contents of a file and return as JSON. """ path = normalize_path(data.path) try: - return path.read_text(encoding="utf-8") + file_content = path.read_text(encoding="utf-8") + return ReadFileResponse(content=file_content) # Return Pydantic model instance + except FileNotFoundError: + raise HTTPException(status_code=404, detail=f"File not found: {data.path}") + except PermissionError: + raise HTTPException(status_code=403, detail=f"Permission denied for file: {data.path}") except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) + # More specific error for generic read issues + raise HTTPException(status_code=500, detail=f"Failed to read file {data.path}: {str(e)}") -@app.post("/write_file", response_class=PlainTextResponse, summary="Write to a file") +@app.post("/write_file", response_model=SuccessResponse, summary="Write to a file") async def write_file(data: WriteFileRequest = Body(...)): """ - Write content to a file, overwriting if it exists. + Write content to a file, overwriting if it exists. Returns JSON success message. """ path = normalize_path(data.path) try: path.write_text(data.content, encoding="utf-8") - return f"Successfully wrote to {data.path}" + return SuccessResponse(message=f"Successfully wrote to {data.path}") + except PermissionError: + raise HTTPException(status_code=403, detail=f"Permission denied to write to {data.path}") except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) + raise HTTPException(status_code=500, detail=f"Failed to write to {data.path}: {str(e)}") +from typing import Union # Add this import at the top with other typing imports + @app.post( - "/edit_file", response_class=PlainTextResponse, summary="Edit a file with diff" + "/edit_file", + response_model=Union[SuccessResponse, DiffResponse], # Use Union for multiple response types + summary="Edit a file with diff" ) async def edit_file(data: EditFileRequest = Body(...)): """ - Apply a list of edits to a text file. Support dry-run to get unified diff. + Apply a list of edits to a text file. + Returns JSON success message or JSON diff on dry-run. """ path = normalize_path(data.path) - original = path.read_text(encoding="utf-8") + try: + original = path.read_text(encoding="utf-8") + except FileNotFoundError: + raise HTTPException(status_code=404, detail=f"File not found: {data.path}") + except PermissionError: + raise HTTPException(status_code=403, detail=f"Permission denied to read file: {data.path}") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to read file {data.path} for editing: {str(e)}") + modified = original + try: + for edit in data.edits: + if edit.oldText not in modified: + raise HTTPException( + status_code=400, + detail=f"Edit failed: oldText not found in content: '{edit.oldText[:50]}...'", + ) + modified = modified.replace(edit.oldText, edit.newText, 1) - for edit in data.edits: - if edit.oldText not in modified: - raise HTTPException( - status_code=400, - detail=f"oldText not found in content: {edit.oldText[:50]}", + if data.dryRun: + diff_output = difflib.unified_diff( + original.splitlines(keepends=True), + modified.splitlines(keepends=True), + fromfile=f"a/{data.path}", + tofile=f"b/{data.path}", ) - modified = modified.replace(edit.oldText, edit.newText, 1) + return DiffResponse(diff="".join(diff_output)) # Return JSON diff - if data.dryRun: - diff = difflib.unified_diff( - original.splitlines(keepends=True), - modified.splitlines(keepends=True), - fromfile="original", - tofile="modified", - ) - return "".join(diff) + # Write changes if not dry run + path.write_text(modified, encoding="utf-8") + return SuccessResponse(message=f"Successfully edited file {data.path}") # Return JSON success - path.write_text(modified, encoding="utf-8") - return f"Successfully edited file {data.path}" + except PermissionError: + raise HTTPException(status_code=403, detail=f"Permission denied to write edited file: {data.path}") + except Exception as e: + # Catch errors during writing the modified file + raise HTTPException(status_code=500, detail=f"Failed to write edited file {data.path}: {str(e)}") @app.post( - "/create_directory", response_class=PlainTextResponse, summary="Create a directory" + "/create_directory", response_model=SuccessResponse, summary="Create a directory" ) async def create_directory(data: CreateDirectoryRequest = Body(...)): """ - Create a new directory recursively. + Create a new directory recursively. Returns JSON success message. """ dir_path = normalize_path(data.path) try: dir_path.mkdir(parents=True, exist_ok=True) - return f"Successfully created directory {data.path}" + return SuccessResponse(message=f"Successfully created directory {data.path}") + except PermissionError: + raise HTTPException(status_code=403, detail=f"Permission denied to create directory {data.path}") except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) + raise HTTPException(status_code=500, detail=f"Failed to create directory {data.path}: {str(e)}") @app.post( @@ -288,11 +330,12 @@ async def search_files(data: SearchFilesRequest = Body(...)): return {"matches": results or ["No matches found"]} -@app.post("/delete_path", response_class=PlainTextResponse, summary="Delete a file or directory") +@app.post("/delete_path", response_model=SuccessResponse, summary="Delete a file or directory") async def delete_path(data: DeletePathRequest = Body(...)): """ Delete a specified file or directory. Requires explicit confirmation. Use 'recursive=True' to delete non-empty directories. + Returns JSON success message. """ if not data.confirm_delete: raise HTTPException( @@ -307,16 +350,16 @@ async def delete_path(data: DeletePathRequest = Body(...)): raise HTTPException(status_code=404, detail=f"Path not found: {data.path}") if path.is_file(): - path.unlink() - return f"Successfully deleted file: {data.path}" + path.unlink() # Raises FileNotFoundError if not exists, PermissionError if no permission + return SuccessResponse(message=f"Successfully deleted file: {data.path}") elif path.is_dir(): if data.recursive: - shutil.rmtree(path) - return f"Successfully deleted directory recursively: {data.path}" + shutil.rmtree(path) # Raises FileNotFoundError, PermissionError, NotADirectoryError + return SuccessResponse(message=f"Successfully deleted directory recursively: {data.path}") else: try: - path.rmdir() # Only works for empty directories - return f"Successfully deleted empty directory: {data.path}" + path.rmdir() # Raises FileNotFoundError, OSError (e.g., dir not empty, permission denied) + return SuccessResponse(message=f"Successfully deleted empty directory: {data.path}") except OSError as e: # Catch error if directory is not empty and recursive is false raise HTTPException( @@ -334,11 +377,12 @@ async def delete_path(data: DeletePathRequest = Body(...)): raise HTTPException(status_code=500, detail=f"Failed to delete {data.path}: {e}") -@app.post("/move_path", response_class=PlainTextResponse, summary="Move or rename a file or directory") +@app.post("/move_path", response_model=SuccessResponse, summary="Move or rename a file or directory") async def move_path(data: MovePathRequest = Body(...)): """ Move or rename a file or directory from source_path to destination_path. Both paths must be within the allowed directories. + Returns JSON success message. """ source = normalize_path(data.source_path) destination = normalize_path(data.destination_path) # Also normalize destination @@ -347,8 +391,8 @@ async def move_path(data: MovePathRequest = Body(...)): if not source.exists(): raise HTTPException(status_code=404, detail=f"Source path not found: {data.source_path}") - shutil.move(str(source), str(destination)) - return f"Successfully moved '{data.source_path}' to '{data.destination_path}'" + shutil.move(str(source), str(destination)) # Raises FileNotFoundError, PermissionError etc. + return SuccessResponse(message=f"Successfully moved '{data.source_path}' to '{data.destination_path}'") except PermissionError: raise HTTPException(status_code=403, detail=f"Permission denied for move operation involving '{data.source_path}' or '{data.destination_path}'")