2026-02-21 02:48:20 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from pathlib import PurePosixPath
|
|
|
|
|
|
|
2026-03-02 00:04:16 +08:00
|
|
|
|
from deepagents.backends import FilesystemBackend
|
2026-05-31 13:36:42 +08:00
|
|
|
|
from deepagents.backends.protocol import (
|
|
|
|
|
|
EditResult,
|
|
|
|
|
|
FileDownloadResponse,
|
|
|
|
|
|
FileInfo,
|
|
|
|
|
|
FileUploadResponse,
|
|
|
|
|
|
GlobResult,
|
|
|
|
|
|
GrepMatch,
|
|
|
|
|
|
GrepResult,
|
|
|
|
|
|
LsResult,
|
|
|
|
|
|
ReadResult,
|
|
|
|
|
|
WriteResult,
|
|
|
|
|
|
)
|
2026-02-21 02:48:20 +08:00
|
|
|
|
|
2026-05-29 22:19:58 +08:00
|
|
|
|
from yuxi.agents.skills.service import get_skills_root_dir, is_valid_skill_slug
|
2026-02-21 02:48:20 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SelectedSkillsReadonlyBackend(FilesystemBackend):
|
|
|
|
|
|
"""只读 skills backend,仅暴露选中的技能目录。"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, *, selected_slugs: list[str] | None):
|
|
|
|
|
|
super().__init__(root_dir=get_skills_root_dir(), virtual_mode=True)
|
|
|
|
|
|
self._selected_slugs = {
|
2026-02-23 20:52:26 +08:00
|
|
|
|
str(slug).strip()
|
|
|
|
|
|
for slug in (selected_slugs or [])
|
|
|
|
|
|
if isinstance(slug, str) and is_valid_skill_slug(str(slug))
|
2026-02-21 02:48:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_slug(self, path: str | None) -> str | None:
|
|
|
|
|
|
if not path:
|
|
|
|
|
|
return None
|
|
|
|
|
|
normalized = (path or "").strip()
|
|
|
|
|
|
if not normalized or normalized == "/":
|
|
|
|
|
|
return None
|
|
|
|
|
|
pure = PurePosixPath(normalized if normalized.startswith("/") else f"/{normalized}")
|
|
|
|
|
|
parts = [p for p in pure.parts if p not in ("/", "")]
|
|
|
|
|
|
return parts[0] if parts else None
|
|
|
|
|
|
|
|
|
|
|
|
def _is_allowed_path(self, path: str | None) -> bool:
|
|
|
|
|
|
slug = self._extract_slug(path)
|
|
|
|
|
|
if slug is None:
|
|
|
|
|
|
return True
|
|
|
|
|
|
return slug in self._selected_slugs
|
|
|
|
|
|
|
|
|
|
|
|
def _is_allowed_file(self, file_path: str) -> bool:
|
|
|
|
|
|
slug = self._extract_slug(file_path)
|
|
|
|
|
|
return slug is not None and slug in self._selected_slugs
|
|
|
|
|
|
|
2026-05-31 13:36:42 +08:00
|
|
|
|
def _filter_infos(self, infos: list[FileInfo]) -> list[FileInfo]:
|
|
|
|
|
|
return [item for item in infos if self._extract_slug(item.get("path", "")) in self._selected_slugs]
|
|
|
|
|
|
|
|
|
|
|
|
def _filter_matches(self, matches: list[GrepMatch]) -> list[GrepMatch]:
|
|
|
|
|
|
return [item for item in matches if self._extract_slug(item.get("path", "")) in self._selected_slugs]
|
|
|
|
|
|
|
|
|
|
|
|
def ls(self, path: str) -> LsResult:
|
2026-02-21 02:48:20 +08:00
|
|
|
|
if not self._selected_slugs:
|
2026-05-31 13:36:42 +08:00
|
|
|
|
return LsResult(entries=[])
|
2026-02-21 02:48:20 +08:00
|
|
|
|
|
|
|
|
|
|
normalized = (path or "/").strip() or "/"
|
|
|
|
|
|
if not self._is_allowed_path(normalized):
|
2026-05-31 13:36:42 +08:00
|
|
|
|
return LsResult(error="Access denied: path is outside selected skills.")
|
2026-02-21 02:48:20 +08:00
|
|
|
|
|
2026-05-31 13:36:42 +08:00
|
|
|
|
result = super().ls(normalized)
|
|
|
|
|
|
if result.error:
|
2026-02-21 02:48:20 +08:00
|
|
|
|
return result
|
2026-05-31 13:36:42 +08:00
|
|
|
|
infos = result.entries or []
|
|
|
|
|
|
if normalized == "/":
|
|
|
|
|
|
infos = self._filter_infos(infos)
|
|
|
|
|
|
return LsResult(entries=infos)
|
2026-02-21 02:48:20 +08:00
|
|
|
|
|
2026-05-31 13:36:42 +08:00
|
|
|
|
def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> ReadResult:
|
2026-02-21 02:48:20 +08:00
|
|
|
|
if not self._is_allowed_file(file_path):
|
2026-05-31 13:36:42 +08:00
|
|
|
|
return ReadResult(error="Access denied: file is outside selected skills.")
|
2026-02-21 02:48:20 +08:00
|
|
|
|
return super().read(file_path, offset=offset, limit=limit)
|
|
|
|
|
|
|
2026-05-31 13:36:42 +08:00
|
|
|
|
def grep(self, pattern: str, path: str | None = None, glob: str | None = None) -> GrepResult:
|
2026-02-21 02:48:20 +08:00
|
|
|
|
if not self._selected_slugs:
|
2026-05-31 13:36:42 +08:00
|
|
|
|
return GrepResult(matches=[])
|
2026-02-21 02:48:20 +08:00
|
|
|
|
|
|
|
|
|
|
if path is not None:
|
|
|
|
|
|
if not self._is_allowed_path(path):
|
2026-05-31 13:36:42 +08:00
|
|
|
|
return GrepResult(error="Access denied: path is outside selected skills.")
|
|
|
|
|
|
result = super().grep(pattern=pattern, path=path, glob=glob)
|
|
|
|
|
|
if result.error:
|
|
|
|
|
|
return result
|
|
|
|
|
|
return GrepResult(matches=self._filter_matches(result.matches or []))
|
2026-02-21 02:48:20 +08:00
|
|
|
|
|
2026-05-31 13:36:42 +08:00
|
|
|
|
matches: list[GrepMatch] = []
|
2026-02-21 02:48:20 +08:00
|
|
|
|
for slug in sorted(self._selected_slugs):
|
2026-05-31 13:36:42 +08:00
|
|
|
|
result = super().grep(pattern=pattern, path=f"/{slug}", glob=glob)
|
|
|
|
|
|
if result.error:
|
2026-02-21 02:48:20 +08:00
|
|
|
|
continue
|
2026-05-31 13:36:42 +08:00
|
|
|
|
matches.extend(result.matches or [])
|
|
|
|
|
|
return GrepResult(matches=matches)
|
2026-02-21 02:48:20 +08:00
|
|
|
|
|
2026-05-31 13:36:42 +08:00
|
|
|
|
def glob(self, pattern: str, path: str = "/") -> GlobResult:
|
2026-02-21 02:48:20 +08:00
|
|
|
|
if not self._selected_slugs:
|
2026-05-31 13:36:42 +08:00
|
|
|
|
return GlobResult(matches=[])
|
2026-02-21 02:48:20 +08:00
|
|
|
|
if not self._is_allowed_path(path):
|
2026-05-31 13:36:42 +08:00
|
|
|
|
return GlobResult(error="Access denied: path is outside selected skills.")
|
|
|
|
|
|
result = super().glob(pattern=pattern, path=path)
|
|
|
|
|
|
if result.error:
|
|
|
|
|
|
return result
|
|
|
|
|
|
return GlobResult(matches=self._filter_infos(result.matches or []))
|
2026-02-21 02:48:20 +08:00
|
|
|
|
|
|
|
|
|
|
def write(self, file_path: str, content: str) -> WriteResult:
|
|
|
|
|
|
return WriteResult(error="Skills path is read-only.")
|
|
|
|
|
|
|
|
|
|
|
|
def edit(self, file_path: str, old_string: str, new_string: str, replace_all: bool = False) -> EditResult:
|
|
|
|
|
|
return EditResult(error="Skills path is read-only.")
|
|
|
|
|
|
|
|
|
|
|
|
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
|
|
|
|
|
|
return [FileUploadResponse(path=p, error="permission_denied") for p, _ in files]
|
|
|
|
|
|
|
|
|
|
|
|
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
|
|
|
|
|
responses: list[FileDownloadResponse] = []
|
|
|
|
|
|
for path in paths:
|
|
|
|
|
|
if not self._is_allowed_file(path):
|
|
|
|
|
|
responses.append(FileDownloadResponse(path=path, content=None, error="invalid_path"))
|
|
|
|
|
|
continue
|
|
|
|
|
|
target = self._resolve_path(path)
|
|
|
|
|
|
if not target.exists():
|
|
|
|
|
|
responses.append(FileDownloadResponse(path=path, content=None, error="file_not_found"))
|
|
|
|
|
|
continue
|
|
|
|
|
|
if target.is_dir():
|
|
|
|
|
|
responses.append(FileDownloadResponse(path=path, content=None, error="is_directory"))
|
|
|
|
|
|
continue
|
|
|
|
|
|
responses.append(FileDownloadResponse(path=path, content=target.read_bytes(), error=None))
|
|
|
|
|
|
return responses
|