* feat: Add workspace: confine agent tools to a folder Pick a server folder as the agent's workspace so its file/shell tools work there and don't touch files outside it. File tools are hard-confined; bash/ python run with cwd set to the folder. Includes a slash command: `/workspace` (alias `/ws`) — show / `set <path>` / `clear` / `pick` (open the directory browser). - routes/workspace_routes.py: GET /api/workspace/browse (admin-only). - src/tool_execution.py: hard path confinement for read_file/write_file; bash/python cwd. Threaded route → stream_agent_loop → execute_tool_block. - src/agent_loop.py: workspace note prepended to the system prompt. - static/: overflow menu item, input-bar pill, directory-browser modal, and the /workspace slash command. - tests/test_workspace_confine.py. * Wire workspace confinement into tools that landed after this PR edit_file (#1239) and grep/glob/ls (#1670) merged after workspace-confine was written, so they bypassed the workspace boundary. Thread the workspace through: - edit_file: _do_edit_file resolves via _resolve_tool_path_in_workspace - grep/glob/ls: _resolve_search_root confines to the workspace (root + paths) - bash/python/bg cwd: workspace or _AGENT_WORKDIR (keep the #2586 data-dir default when no workspace is set) Tests cover edit_file + grep/ls confinement (inside ok, outside rejected). * Workspace picker: editable path bar + modal style cohesion + cross-platform hardening - Make the current-folder strip an editable address bar: type/paste a full path and press Enter to navigate (also reaches other Windows drives and hidden dirs the up-only browser cannot). - Reuse shared modal CSS: drop bespoke .workspace-modal-content/.workspace-btn* in favour of base .modal-content/.modal-body and the .confirm-btn button family; separators/hover use var(--border). Net -31 CSS lines. - Fix the path field overflowing the modal right edge (flex stretch + margin vs an overflow:auto scrollbar-feedback loop): full-bleed, no h-margin. - Cross-platform confinement: normcase the workspace commonpath check so containment holds on case-insensitive filesystems (Windows/macOS). - Make tests OS-portable: sibling temp dirs instead of /etc, python os.getcwd() instead of pwd. 5 pass.
57 lines
2.5 KiB
Python
57 lines
2.5 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
|
|
from src.tool_security import owner_is_admin_or_single_user
|
|
|
|
|
|
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.
|
|
"""
|
|
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 = []
|
|
|
|
parent = os.path.dirname(target)
|
|
return {
|
|
"path": target,
|
|
"parent": parent if parent and parent != target else None,
|
|
"dirs": sorted(dirs, key=lambda d: d["name"].lower()),
|
|
}
|
|
|
|
return router
|