diff --git a/internal/http/skills.go b/internal/http/skills.go index 3f083ff5..5364dba4 100644 --- a/internal/http/skills.go +++ b/internal/http/skills.go @@ -11,6 +11,8 @@ import ( "os" "path/filepath" "regexp" + "sort" + "strconv" "strings" "github.com/google/uuid" @@ -61,6 +63,9 @@ func (h *SkillsHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /v1/skills/{id}/grants/user", h.authMiddleware(h.handleGrantUser)) mux.HandleFunc("DELETE /v1/skills/{id}/grants/user/{userID}", h.authMiddleware(h.handleRevokeUser)) mux.HandleFunc("GET /v1/agents/{agentID}/skills", h.authMiddleware(h.handleListAgentSkills)) + mux.HandleFunc("GET /v1/skills/{id}/versions", h.authMiddleware(h.handleListVersions)) + mux.HandleFunc("GET /v1/skills/{id}/files/{path...}", h.authMiddleware(h.handleReadFile)) + mux.HandleFunc("GET /v1/skills/{id}/files", h.authMiddleware(h.handleListFiles)) } func (h *SkillsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc { @@ -239,6 +244,10 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) { if f.FileInfo().IsDir() { continue } + // Skip symlinks in ZIP — prevent directory escape attacks + if f.Mode()&os.ModeSymlink != 0 { + continue + } // Strip wrapper directory prefix if ZIP had one entryName := strings.TrimPrefix(f.Name, "./") if stripPrefix != "" { @@ -247,12 +256,19 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) { continue } } + // Skip macOS/system artifacts + if isSystemArtifact(entryName) { + continue + } // Security: prevent path traversal name := filepath.Clean(entryName) if strings.Contains(name, "..") { continue } destPath := filepath.Join(destDir, name) + if !strings.HasPrefix(destPath, destDir+string(filepath.Separator)) { + continue + } if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { continue } @@ -312,3 +328,202 @@ func (h *SkillsHandler) handleListAgentSkills(w http.ResponseWriter, r *http.Req writeJSON(w, http.StatusOK, map[string]interface{}{"skills": skills}) } + +// handleListVersions returns all available version numbers for a skill. +func (h *SkillsHandler) handleListVersions(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"}) + return + } + + _, slug, currentVersion, ok := h.skills.GetSkillFilePath(id) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "skill not found"}) + return + } + + slugDir := filepath.Join(h.baseDir, slug) + entries, err := os.ReadDir(slugDir) + if err != nil { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "versions": []int{currentVersion}, + "current": currentVersion, + }) + return + } + + var versions []int + for _, e := range entries { + if !e.IsDir() { + continue + } + v, err := strconv.Atoi(e.Name()) + if err != nil || v < 1 { + continue + } + versions = append(versions, v) + } + sort.Ints(versions) + if len(versions) == 0 { + versions = []int{currentVersion} + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "versions": versions, + "current": currentVersion, + }) +} + +// handleListFiles returns all files in a skill version directory. +func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"}) + return + } + + _, slug, currentVersion, ok := h.skills.GetSkillFilePath(id) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "skill not found"}) + return + } + + version := currentVersion + if v := r.URL.Query().Get("version"); v != "" { + parsed, err := strconv.Atoi(v) + if err != nil || parsed < 1 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid version"}) + return + } + version = parsed + } + + versionDir := filepath.Join(h.baseDir, slug, strconv.Itoa(version)) + if _, err := os.Stat(versionDir); err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "version not found"}) + return + } + + type fileEntry struct { + Path string `json:"path"` + Name string `json:"name"` + IsDir bool `json:"isDir"` + Size int64 `json:"size"` + } + + var files []fileEntry + filepath.WalkDir(versionDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + rel, _ := filepath.Rel(versionDir, path) + if rel == "." { + return nil + } + // Skip system artifacts (__MACOSX, .DS_Store, etc.) + if isSystemArtifact(rel) { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + // Skip symlinks — prevent escape from skill directory + if d.Type()&os.ModeSymlink != 0 { + return nil + } + entry := fileEntry{ + Path: rel, + Name: d.Name(), + IsDir: d.IsDir(), + } + if !d.IsDir() { + if info, err := d.Info(); err == nil { + entry.Size = info.Size() + } + } + files = append(files, entry) + return nil + }) + + if files == nil { + files = []fileEntry{} + } + writeJSON(w, http.StatusOK, map[string]interface{}{"files": files}) +} + +// handleReadFile reads a single file from a skill version directory. +func (h *SkillsHandler) handleReadFile(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"}) + return + } + + relPath := r.PathValue("path") + if relPath == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path is required"}) + return + } + if strings.Contains(relPath, "..") { + slog.Warn("security.skill_files_traversal", "path", relPath) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) + return + } + + _, slug, currentVersion, ok := h.skills.GetSkillFilePath(id) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "skill not found"}) + return + } + + version := currentVersion + if v := r.URL.Query().Get("version"); v != "" { + parsed, err := strconv.Atoi(v) + if err != nil || parsed < 1 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid version"}) + return + } + version = parsed + } + + versionDir := filepath.Join(h.baseDir, slug, strconv.Itoa(version)) + absPath := filepath.Join(versionDir, filepath.Clean(relPath)) + + // Verify resolved path is within the version directory + if !strings.HasPrefix(absPath, versionDir+string(filepath.Separator)) { + slog.Warn("security.skill_files_escape", "resolved", absPath, "root", versionDir) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) + return + } + + // Use Lstat to detect symlinks — reject them to prevent directory escape + info, err := os.Lstat(absPath) + if err != nil || info.IsDir() { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "file not found"}) + return + } + if info.Mode()&os.ModeSymlink != 0 { + slog.Warn("security.skill_files_symlink", "path", absPath) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) + return + } + + // Skip system artifacts + if isSystemArtifact(relPath) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "file not found"}) + return + } + + data, err := os.ReadFile(absPath) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read file"}) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "content": string(data), + "path": relPath, + "size": info.Size(), + }) +} diff --git a/internal/http/skills_grants.go b/internal/http/skills_grants.go index 6ef1c877..09ee3517 100644 --- a/internal/http/skills_grants.go +++ b/internal/http/skills_grants.go @@ -5,6 +5,7 @@ import ( "encoding/json" "io" "net/http" + "path/filepath" "strings" "github.com/google/uuid" @@ -188,6 +189,28 @@ func parseSkillFrontmatter(content string) (name, description, slug string, allF return } +// isSystemArtifact returns true for OS-generated junk that should be skipped +// during ZIP extraction and file listing (e.g. __MACOSX, .DS_Store, Thumbs.db). +func isSystemArtifact(name string) bool { + base := filepath.Base(name) + // macOS resource fork / metadata folders and files + if base == "__MACOSX" || strings.HasPrefix(base, "._") { + return true + } + // Check if any path component is __MACOSX + for _, part := range strings.Split(filepath.ToSlash(name), "/") { + if part == "__MACOSX" { + return true + } + } + // Common OS junk files + switch base { + case ".DS_Store", "Thumbs.db", "desktop.ini", ".Spotlight-V100", ".Trashes", ".fseventsd": + return true + } + return false +} + func slugify(name string) string { s := strings.ToLower(name) s = strings.Map(func(r rune) rune { diff --git a/internal/store/pg/skills.go b/internal/store/pg/skills.go index ff7d8e8e..77c9344c 100644 --- a/internal/store/pg/skills.go +++ b/internal/store/pg/skills.go @@ -301,6 +301,14 @@ func (s *PGSkillStore) CreateSkillManaged(ctx context.Context, p SkillCreatePara return id, err } +// GetSkillFilePath returns the filesystem path and version for a skill by UUID. +func (s *PGSkillStore) GetSkillFilePath(id uuid.UUID) (filePath string, slug string, version int, ok bool) { + err := s.db.QueryRow( + "SELECT file_path, slug, version FROM skills WHERE id = $1 AND status = 'active'", id, + ).Scan(&filePath, &slug, &version) + return filePath, slug, version, err == nil +} + // GetNextVersion returns the next version number for a skill slug. func (s *PGSkillStore) GetNextVersion(slug string) int { var maxVersion int diff --git a/ui/web/src/pages/skills/hooks/use-skills.ts b/ui/web/src/pages/skills/hooks/use-skills.ts index d6178b40..baf8fa93 100644 --- a/ui/web/src/pages/skills/hooks/use-skills.ts +++ b/ui/web/src/pages/skills/hooks/use-skills.ts @@ -4,9 +4,9 @@ import { useWs, useHttp } from "@/hooks/use-ws"; import { useAuthStore } from "@/stores/use-auth-store"; import { Methods } from "@/api/protocol"; import { queryKeys } from "@/lib/query-keys"; -import type { SkillInfo } from "@/types/skill"; +import type { SkillInfo, SkillFile, SkillVersions } from "@/types/skill"; -export type { SkillInfo }; +export type { SkillInfo, SkillFile, SkillVersions }; export function useSkills() { const ws = useWs(); @@ -69,5 +69,35 @@ export function useSkills() { [http, invalidate], ); - return { skills, loading, refresh: invalidate, getSkill, uploadSkill, updateSkill, deleteSkill }; + const getSkillVersions = useCallback( + async (id: string) => { + return http.get(`/v1/skills/${id}/versions`); + }, + [http], + ); + + const getSkillFiles = useCallback( + async (id: string, version?: number) => { + const q = version != null ? `?version=${version}` : ""; + const res = await http.get<{ files: SkillFile[] }>(`/v1/skills/${id}/files${q}`); + return res.files ?? []; + }, + [http], + ); + + const getSkillFileContent = useCallback( + async (id: string, path: string, version?: number) => { + const q = version != null ? `?version=${version}` : ""; + return http.get<{ content: string; path: string; size: number }>( + `/v1/skills/${id}/files/${encodeURIComponent(path)}${q}`, + ); + }, + [http], + ); + + return { + skills, loading, refresh: invalidate, getSkill, + uploadSkill, updateSkill, deleteSkill, + getSkillVersions, getSkillFiles, getSkillFileContent, + }; } diff --git a/ui/web/src/pages/skills/skill-detail-dialog.tsx b/ui/web/src/pages/skills/skill-detail-dialog.tsx index 776d2e4c..c1d9227c 100644 --- a/ui/web/src/pages/skills/skill-detail-dialog.tsx +++ b/ui/web/src/pages/skills/skill-detail-dialog.tsx @@ -1,24 +1,338 @@ +import { useState, useEffect, useCallback, useMemo } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Badge } from "@/components/ui/badge"; import { MarkdownRenderer } from "@/components/shared/markdown-renderer"; -import type { SkillInfo } from "./hooks/use-skills"; +import { useClipboard } from "@/hooks/use-clipboard"; +import { + Folder, + FolderOpen, + FileText, + FileCode2, + File, + ChevronRight, + Check, + Copy, + Loader2, +} from "lucide-react"; +import type { SkillInfo, SkillFile, SkillVersions } from "@/types/skill"; + +// --- Types --- interface SkillDetailDialogProps { skill: SkillInfo & { content: string }; onClose: () => void; + getSkillVersions: (id: string) => Promise; + getSkillFiles: (id: string, version?: number) => Promise; + getSkillFileContent: (id: string, path: string, version?: number) => Promise<{ content: string; path: string; size: number }>; } -export function SkillDetailDialog({ skill, onClose }: SkillDetailDialogProps) { +interface TreeNode { + name: string; + path: string; + isDir: boolean; + size: number; + children: TreeNode[]; +} + +// --- Helpers --- + +const CODE_EXTENSIONS = new Set([ + "ts", "tsx", "js", "jsx", "py", "go", "json", "yaml", "yml", "toml", + "sh", "bash", "css", "html", "sql", "rs", "rb", "java", "kt", "swift", + "c", "cpp", "h", "xml", "graphql", "proto", "lua", "zig", "env", +]); + +function extOf(name: string): string { + const dot = name.lastIndexOf("."); + return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ""; +} + +function langFor(ext: string): string { + const map: Record = { + ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx", + py: "python", go: "go", rs: "rust", rb: "ruby", sh: "bash", bash: "bash", + yml: "yaml", yaml: "yaml", json: "json", toml: "toml", + css: "css", html: "html", sql: "sql", xml: "xml", + java: "java", kt: "kotlin", swift: "swift", + c: "c", cpp: "cpp", h: "c", graphql: "graphql", + proto: "protobuf", lua: "lua", zig: "zig", + }; + return map[ext] ?? ext; +} + +function buildTree(files: SkillFile[]): TreeNode[] { + const root: TreeNode[] = []; + const dirMap = new Map(); + + // Sort: dirs first, then alphabetically + const sorted = [...files].sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.path.localeCompare(b.path); + }); + + for (const f of sorted) { + const node: TreeNode = { + name: f.name, + path: f.path, + isDir: f.isDir, + size: f.size, + children: [], + }; + + if (f.isDir) { + dirMap.set(f.path, node); + } + + // Find parent dir + const parentPath = f.path.includes("/") + ? f.path.slice(0, f.path.lastIndexOf("/")) + : ""; + + if (parentPath && dirMap.has(parentPath)) { + dirMap.get(parentPath)!.children.push(node); + } else { + root.push(node); + } + } + + // Sort children: dirs first, then alphabetically + const sortChildren = (nodes: TreeNode[]) => { + nodes.sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name); + }); + for (const n of nodes) { + if (n.children.length > 0) sortChildren(n.children); + } + }; + sortChildren(root); + return root; +} + +function stripFrontmatter(content: string): string { + if (!content.startsWith("---")) return content; + const end = content.indexOf("---", 3); + if (end < 0) return content; + return content.slice(end + 3).replace(/^\n+/, ""); +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function FileIcon({ name }: { name: string }) { + const ext = extOf(name); + if (ext === "md") return ; + if (CODE_EXTENSIONS.has(ext)) return ; + return ; +} + +// --- Components --- + +function TreeItem({ + node, + depth, + activePath, + onSelect, +}: { + node: TreeNode; + depth: number; + activePath: string | null; + onSelect: (path: string) => void; +}) { + const [expanded, setExpanded] = useState(depth === 0); + const isActive = activePath === node.path; + + if (node.isDir) { + return ( +
+ + {expanded && node.children.map((child) => ( + + ))} +
+ ); + } + + return ( + + ); +} + +function CodeViewer({ content, language }: { content: string; language: string }) { + const { copied, copy } = useClipboard(); + + return ( +
+
+ {language || "text"} + +
+
+        {content}
+      
+
+ ); +} + +function FileContentViewer({ path, content, size }: { path: string; content: string; size: number }) { + const ext = extOf(path); + const displayContent = ext === "md" ? stripFrontmatter(content) : content; + + return ( +
+
+ {path} + {formatSize(size)} +
+ {ext === "md" ? ( + + ) : CODE_EXTENSIONS.has(ext) ? ( + + ) : ( +
+          {displayContent}
+        
+ )} +
+ ); +} + +// --- Main Component --- + +export function SkillDetailDialog({ + skill, + onClose, + getSkillVersions, + getSkillFiles, + getSkillFileContent, +}: SkillDetailDialogProps) { + const hasFiles = !!skill.id; + + // Version state + const [versions, setVersions] = useState(null); + const [selectedVersion, setSelectedVersion] = useState(null); + + // File tree state + const [files, setFiles] = useState([]); + const [filesLoading, setFilesLoading] = useState(false); + const [activePath, setActivePath] = useState(null); + + // File content state + const [fileContent, setFileContent] = useState<{ content: string; path: string; size: number } | null>(null); + const [contentLoading, setContentLoading] = useState(false); + + const tree = useMemo(() => buildTree(files), [files]); + + // Load versions when Files tab is first opened + const loadVersions = useCallback(async () => { + if (!skill.id || versions) return; + const v = await getSkillVersions(skill.id); + setVersions(v); + setSelectedVersion(v.current); + }, [skill.id, versions, getSkillVersions]); + + // Load files for selected version + const loadFiles = useCallback(async (version?: number) => { + if (!skill.id) return; + setFilesLoading(true); + try { + const f = await getSkillFiles(skill.id, version); + setFiles(f); + setActivePath(null); + setFileContent(null); + } finally { + setFilesLoading(false); + } + }, [skill.id, getSkillFiles]); + + // Load file content + const loadFileContent = useCallback(async (path: string) => { + if (!skill.id) return; + setActivePath(path); + setContentLoading(true); + try { + const c = await getSkillFileContent(skill.id, path, selectedVersion ?? undefined); + setFileContent(c); + } finally { + setContentLoading(false); + } + }, [skill.id, selectedVersion, getSkillFileContent]); + + // On version change + useEffect(() => { + if (selectedVersion != null) { + loadFiles(selectedVersion); + } + }, [selectedVersion, loadFiles]); + + const handleTabChange = (tab: string) => { + if (tab === "files" && hasFiles) { + loadVersions(); + if (files.length === 0 && !filesLoading) { + loadFiles(selectedVersion ?? undefined); + } + } + }; + return ( onClose()}> - + - + {skill.name} {skill.source || "file"} {skill.visibility && ( @@ -39,15 +353,92 @@ export function SkillDetailDialog({ skill, onClose }: SkillDetailDialogProps) { )} -
- {skill.content ? ( -
- -
- ) : ( -

No content available.

+ + + + Content + {hasFiles && Files} + + + + {skill.content ? ( +
+ +
+ ) : ( +

No content available.

+ )} +
+ + {hasFiles && ( + + {/* Version selector */} + {versions && versions.versions.length > 1 && ( +
+ Version: + +
+ )} + + {/* File browser */} +
+ {/* Tree panel */} +
+ {filesLoading ? ( +
+ +
+ ) : tree.length === 0 ? ( +

No files found.

+ ) : ( + tree.map((node) => ( + + )) + )} +
+ + {/* Content panel */} +
+ {contentLoading ? ( +
+ +
+ ) : fileContent ? ( + + ) : ( +
+ Select a file to view its content +
+ )} +
+
+
)} -
+
); diff --git a/ui/web/src/pages/skills/skills-page.tsx b/ui/web/src/pages/skills/skills-page.tsx index 7c5c5614..a23ae09f 100644 --- a/ui/web/src/pages/skills/skills-page.tsx +++ b/ui/web/src/pages/skills/skills-page.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { Zap, Eye, Pencil, RefreshCw, Upload, Trash2 } from "lucide-react"; +import { Zap, Pencil, RefreshCw, Upload, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { PageHeader } from "@/components/shared/page-header"; @@ -23,12 +23,14 @@ const visibilityColor: Record = { }; export function SkillsPage() { - const { skills, loading, refresh, getSkill, uploadSkill, updateSkill, deleteSkill } = useSkills(); + const { + skills, loading, refresh, getSkill, uploadSkill, updateSkill, deleteSkill, + getSkillVersions, getSkillFiles, getSkillFileContent, + } = useSkills(); const spinning = useMinLoading(loading); const showSkeleton = useDeferredLoading(loading && skills.length === 0); const [search, setSearch] = useState(""); const [selectedSkill, setSelectedSkill] = useState<(SkillInfo & { content: string }) | null>(null); - const [detailLoading, setDetailLoading] = useState(false); const [uploadOpen, setUploadOpen] = useState(false); const [editTarget, setEditTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); @@ -45,13 +47,8 @@ export function SkillsPage() { useEffect(() => { resetPage(); }, [search, resetPage]); const handleViewSkill = async (name: string) => { - setDetailLoading(true); - try { - const detail = await getSkill(name); - if (detail) setSelectedSkill(detail); - } finally { - setDetailLoading(false); - } + const detail = await getSkill(name); + if (detail) setSelectedSkill(detail); }; const handleUpload = async (file: File) => { @@ -124,7 +121,13 @@ export function SkillsPage() {
- {skill.name} + {skill.version ? ( v{skill.version} ) : null} @@ -145,15 +148,6 @@ export function SkillsPage() {
- {skill.id && ( <>