from __future__ import annotations from pathlib import PurePosixPath from deepagents.backends import FilesystemBackend from deepagents.backends.protocol import ( EditResult, FileDownloadResponse, FileInfo, FileUploadResponse, GlobResult, GrepMatch, GrepResult, LsResult, ReadResult, WriteResult, ) from yuxi.agents.skills.service import get_skills_root_dir, is_valid_skill_slug 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 = { str(slug).strip() for slug in (selected_slugs or []) if isinstance(slug, str) and is_valid_skill_slug(str(slug)) } 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 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: if not self._selected_slugs: return LsResult(entries=[]) normalized = (path or "/").strip() or "/" if not self._is_allowed_path(normalized): return LsResult(error="Access denied: path is outside selected skills.") result = super().ls(normalized) if result.error: return result infos = result.entries or [] if normalized == "/": infos = self._filter_infos(infos) return LsResult(entries=infos) def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> ReadResult: if not self._is_allowed_file(file_path): return ReadResult(error="Access denied: file is outside selected skills.") return super().read(file_path, offset=offset, limit=limit) def grep(self, pattern: str, path: str | None = None, glob: str | None = None) -> GrepResult: if not self._selected_slugs: return GrepResult(matches=[]) if path is not None: if not self._is_allowed_path(path): 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 [])) matches: list[GrepMatch] = [] for slug in sorted(self._selected_slugs): result = super().grep(pattern=pattern, path=f"/{slug}", glob=glob) if result.error: continue matches.extend(result.matches or []) return GrepResult(matches=matches) def glob(self, pattern: str, path: str = "/") -> GlobResult: if not self._selected_slugs: return GlobResult(matches=[]) if not self._is_allowed_path(path): 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 [])) 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