mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-17 05:32:20 +02:00
88 lines
4.1 KiB
Python
88 lines
4.1 KiB
Python
"""Workspace API - browse server directories to pick a tool workspace folder."""
|
|
import os
|
|
from fastapi import APIRouter, Request, HTTPException, Query
|
|
|
|
from src.auth_helpers import get_current_user, require_non_bearer_request
|
|
from src.tool_security import owner_is_admin_or_single_user
|
|
|
|
# Cap entries returned per directory (mirrors filesystem_tools._CODENAV_MAX_HITS).
|
|
# A huge directory shouldn't dump thousands of rows into the picker; the user can
|
|
# type/paste a path to jump straight in instead.
|
|
_MAX_BROWSE_DIRS = 500
|
|
|
|
|
|
def setup_workspace_routes():
|
|
router = APIRouter(prefix="/api/workspace", tags=["workspace"])
|
|
|
|
@router.get("/browse")
|
|
def browse(request: Request, path: str = Query(default="")):
|
|
"""List subdirectories of `path` (default: home) so the UI can navigate
|
|
the server filesystem and pick a workspace folder. Directories only.
|
|
|
|
ADMIN-ONLY: this enumerates the server filesystem, so it is gated the
|
|
same way the file/shell tools are (read_file/write_file/bash are in
|
|
NON_ADMIN_BLOCKED_TOOLS). A non-admin who can't use those tools must not
|
|
be able to map the host's directory tree either.
|
|
"""
|
|
require_non_bearer_request(request)
|
|
owner = get_current_user(request)
|
|
if not owner_is_admin_or_single_user(owner):
|
|
raise HTTPException(status_code=403, detail="Workspace browsing is admin-only")
|
|
|
|
# Resolve symlinks so the reported path is canonical and the UI navigates
|
|
# real directories (defends against symlink games in displayed paths).
|
|
target = os.path.realpath(os.path.expanduser(path.strip() or "~"))
|
|
if not os.path.isdir(target):
|
|
target = os.path.realpath(os.path.expanduser("~"))
|
|
|
|
dirs = []
|
|
try:
|
|
with os.scandir(target) as it:
|
|
for entry in it:
|
|
try:
|
|
# Don't follow symlinks when classifying - a symlinked
|
|
# dir is skipped rather than letting the browser wander
|
|
# off via a link. Hidden entries are omitted.
|
|
if entry.is_dir(follow_symlinks=False) and not entry.name.startswith("."):
|
|
# Build the child path server-side with os.path.join
|
|
# so it's correct on Windows (backslashes) and Linux.
|
|
dirs.append({"name": entry.name, "path": os.path.join(target, entry.name)})
|
|
except OSError:
|
|
continue
|
|
except (PermissionError, OSError):
|
|
dirs = []
|
|
|
|
dirs_sorted = sorted(dirs, key=lambda d: d["name"].lower())
|
|
truncated = len(dirs_sorted) > _MAX_BROWSE_DIRS
|
|
parent = os.path.dirname(target)
|
|
from src.tool_execution import vet_workspace
|
|
return {
|
|
"path": target,
|
|
"parent": parent if parent and parent != target else None,
|
|
"dirs": dirs_sorted[:_MAX_BROWSE_DIRS],
|
|
"truncated": truncated,
|
|
# Whether this directory may be bound as a workspace (filesystem
|
|
# roots and sensitive dirs may be browsed through but not chosen).
|
|
"selectable": vet_workspace(target) is not None,
|
|
}
|
|
|
|
@router.get("/vet")
|
|
def vet(request: Request, path: str = Query(default="")):
|
|
"""Validate a workspace path without binding it.
|
|
|
|
The UI calls this before persisting a manually typed path (/workspace
|
|
set) so a typo, file path, deleted folder, sensitive dir, or filesystem
|
|
root is rejected up front with the canonical path returned on success,
|
|
instead of being stored client-side and silently dropped at chat time.
|
|
Admin-gated like /browse: it confirms path existence on the host.
|
|
"""
|
|
require_non_bearer_request(request)
|
|
owner = get_current_user(request)
|
|
if not owner_is_admin_or_single_user(owner):
|
|
raise HTTPException(status_code=403, detail="Workspace selection is admin-only")
|
|
from src.tool_execution import vet_workspace
|
|
resolved = vet_workspace(path)
|
|
return {"ok": resolved is not None, "path": resolved}
|
|
|
|
return router
|