mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
* feat(hooks/observe): add structured output validation hook (ObserveHook) - hooks/types.go: Add ObserveHook interface + BuiltInHookType enum - hooks/dispatcher.go: HookDispatcher emits ObserveHook with PhaseResult payload - pipeline/observe_stage.go: ObserveStage emits hook after ObserveResult built - pipeline/substates.go: ObserveStageResult carries hook results + validation errors - hooks/config.go: BuiltInHookTypeObserve added to BuiltInHookType enum PhaseResult carries structured output, token usage, tool calls + validation errors emitted post-ObserveStage. ObserveHook implementations can validate structured output against schemas, detect tool-call loops, enforce token budgets, etc. Hook fires after ObserveStage produces ObserveResult, before results propagate to next stage. ValidationError returned by hook halts pipeline and propagates error to caller without further stage execution. Co-Authored-By: Claude <noreply@anthropic.com> * ui(hooks): add post_model_response event to web UI - Add event to Zod schema, filter dropdown, and form dialog - Implement conditional test panel UI for model response payload - Add translations (en/zh/vi) for new test panel fields - Updated beta description to reference the new event Co-Authored-By: Claude <noreply@anthropic.com> * feat(mcp): add MCP CRUD server exposing goclaw resources at /api/mcp/ with Bearer token auth and X-GoClaw-Tenant-Id header, default to master tenant * feat(mcp): add goclaw_skills_write_file tool to edit skill files on disk The CRUD MCP server's goclaw_skills_update only touched skill DB metadata, with no way to edit a skill's SKILL.md/file content on the filesystem. Extract the versioned write logic from the web UI's skill file editor (SkillsHandler.handleWriteFile) into skills.WriteVersionedFile so both surfaces share identical validation and versioning, and expose it as a new MCP tool. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
377 lines
12 KiB
Go
377 lines
12 KiB
Go
package http
|
|
|
|
import (
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/skills"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// fileEntry represents a file or directory in a skill version directory.
|
|
type fileEntry struct {
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
IsDir bool `json:"isDir"`
|
|
Size int64 `json:"size"`
|
|
}
|
|
|
|
// walkSkillFiles returns all files/dirs under root, skipping system artifacts and symlinks.
|
|
func walkSkillFiles(root string) []fileEntry {
|
|
var files []fileEntry
|
|
filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
rel, _ := filepath.Rel(root, path)
|
|
if rel == "." {
|
|
return nil
|
|
}
|
|
if skills.IsSystemArtifact(rel) {
|
|
if d.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
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
|
|
})
|
|
return files
|
|
}
|
|
|
|
// handleListVersions returns all available version numbers for a skill.
|
|
func (h *SkillsHandler) handleListVersions(w http.ResponseWriter, r *http.Request) {
|
|
locale := store.LocaleFromContext(r.Context())
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
|
|
return
|
|
}
|
|
|
|
filePath, _, currentVersion, _, ok := h.skills.GetSkillFilePath(r.Context(), id)
|
|
if !ok {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id.String())})
|
|
return
|
|
}
|
|
|
|
slugDir := store.SkillSlugDir(filePath)
|
|
if slugDir == "" {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"versions": []int{currentVersion},
|
|
"current": currentVersion,
|
|
})
|
|
return
|
|
}
|
|
|
|
entries, err := os.ReadDir(slugDir)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"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]any{
|
|
"versions": versions,
|
|
"current": currentVersion,
|
|
})
|
|
}
|
|
|
|
// handleListFiles returns all files in a skill version directory.
|
|
func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request) {
|
|
locale := store.LocaleFromContext(r.Context())
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
|
|
return
|
|
}
|
|
|
|
filePath, slug, currentVersion, isSystem, ok := h.skills.GetSkillFilePath(r.Context(), id)
|
|
if !ok {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id.String())})
|
|
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": i18n.T(locale, i18n.MsgInvalidVersion)})
|
|
return
|
|
}
|
|
version = parsed
|
|
}
|
|
|
|
slugDir := store.SkillSlugDir(filePath)
|
|
if slugDir == "" {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgVersionNotFound)})
|
|
return
|
|
}
|
|
|
|
versionDir := filepath.Join(slugDir, strconv.Itoa(version))
|
|
if _, err := os.Stat(versionDir); err != nil {
|
|
if !isSystem || h.bundledDir == "" {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgVersionNotFound)})
|
|
return
|
|
}
|
|
bundledDir := filepath.Join(h.bundledDir, slug)
|
|
if _, err := os.Stat(bundledDir); err != nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgVersionNotFound)})
|
|
return
|
|
}
|
|
files := walkSkillFiles(bundledDir)
|
|
if files == nil {
|
|
files = []fileEntry{}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"files": files})
|
|
return
|
|
}
|
|
|
|
files := walkSkillFiles(versionDir)
|
|
|
|
// Fallback: if managed dir has no files (seeder CopyDir may have failed),
|
|
// try the bundled skills dir — only for system skills to prevent slug collision attacks.
|
|
if len(files) == 0 && isSystem && h.bundledDir != "" {
|
|
bundledDir := filepath.Join(h.bundledDir, slug)
|
|
if _, err := os.Stat(bundledDir); err == nil {
|
|
files = walkSkillFiles(bundledDir)
|
|
}
|
|
}
|
|
|
|
if files == nil {
|
|
files = []fileEntry{}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"files": files})
|
|
}
|
|
|
|
// handleReadFile reads a single file from a skill version directory.
|
|
func (h *SkillsHandler) handleReadFile(w http.ResponseWriter, r *http.Request) {
|
|
locale := store.LocaleFromContext(r.Context())
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
|
|
return
|
|
}
|
|
|
|
relPath := r.PathValue("path")
|
|
if relPath == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "path")})
|
|
return
|
|
}
|
|
if strings.Contains(relPath, "..") {
|
|
slog.Warn("security.skill_files_traversal", "path", relPath)
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
|
|
return
|
|
}
|
|
|
|
filePath, slug, currentVersion, isSystem, ok := h.skills.GetSkillFilePath(r.Context(), id)
|
|
if !ok {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id.String())})
|
|
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": i18n.T(locale, i18n.MsgInvalidVersion)})
|
|
return
|
|
}
|
|
version = parsed
|
|
}
|
|
|
|
slugDir := store.SkillSlugDir(filePath)
|
|
if slugDir == "" {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)})
|
|
return
|
|
}
|
|
|
|
versionDir := filepath.Join(slugDir, strconv.Itoa(version))
|
|
roots := readableSkillRoots(versionDir, slug, isSystem, h.bundledDir)
|
|
if len(roots) == 0 {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)})
|
|
return
|
|
}
|
|
|
|
cleanRelPath := filepath.Clean(relPath)
|
|
var data []byte
|
|
var info os.FileInfo
|
|
var readErr error = os.ErrNotExist
|
|
for _, root := range roots {
|
|
absPath := filepath.Join(root, cleanRelPath)
|
|
if !strings.HasPrefix(absPath, root+string(filepath.Separator)) {
|
|
slog.Warn("security.skill_files_escape", "resolved", absPath, "root", root)
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
|
|
return
|
|
}
|
|
data, info, readErr = readSkillFile(absPath)
|
|
if readErr == nil {
|
|
break
|
|
}
|
|
}
|
|
if readErr != nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"content": string(data),
|
|
"path": relPath,
|
|
"size": info.Size(),
|
|
})
|
|
}
|
|
|
|
// handleWriteFile writes a single file's content, creating a new immutable
|
|
// version of a managed (non-system) skill — mirroring the skill_manage tool's
|
|
// patch action (see internal/tools/skill_manage.go) and the skill evolution
|
|
// apply path (applySkillSuggestionPatch in skills_evolution.go): the current
|
|
// version directory is copied to a new version directory, the target file is
|
|
// updated there, and the skill's DB row is repointed at the new version.
|
|
// System/bundled skills are read-only via the API — editing them here would
|
|
// silently diverge from the shipped source and is rejected. Historical
|
|
// versions remain immutable.
|
|
func (h *SkillsHandler) handleWriteFile(w http.ResponseWriter, r *http.Request) {
|
|
locale := store.LocaleFromContext(r.Context())
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
|
|
return
|
|
}
|
|
|
|
relPath := r.PathValue("path")
|
|
if relPath == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "path")})
|
|
return
|
|
}
|
|
if strings.Contains(relPath, "..") {
|
|
slog.Warn("security.skill_files_traversal", "path", relPath)
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
Content string `json:"content"`
|
|
}
|
|
if !bindJSON(w, r, locale, &body) {
|
|
return
|
|
}
|
|
|
|
_, _, _, isSystem, ok := h.skills.GetSkillFilePath(r.Context(), id)
|
|
if !ok {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id.String())})
|
|
return
|
|
}
|
|
if isSystem {
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "cannot edit a system skill"})
|
|
return
|
|
}
|
|
|
|
// Ownership check (admins bypass) — mirrors handleUpdate/handleDelete.
|
|
auth := resolveAuth(r)
|
|
if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) {
|
|
userID := store.UserIDFromContext(r.Context())
|
|
if ownerID, found := h.skills.GetSkillOwnerID(r.Context(), id); found && ownerID != userID {
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"})
|
|
return
|
|
}
|
|
}
|
|
|
|
path, newVersion, err := skills.WriteVersionedFile(r.Context(), h.skills, h.tenantSkillsDir(r), id, relPath, body.Content)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, skills.ErrSkillFileNotFound):
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)})
|
|
case errors.Is(err, skills.ErrSkillIsSystem):
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "cannot edit a system skill"})
|
|
case errors.Is(err, skills.ErrSkillInvalidPath):
|
|
slog.Warn("security.skill_files_escape", "path", relPath, "skill_id", id.String())
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
|
|
default:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return
|
|
}
|
|
|
|
h.emitCacheInvalidate(bus.CacheKindSkills, id.String(), uuid.Nil)
|
|
emitAudit(h.msgBus, r, "skill.file_updated", "skill", id.String())
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": "true", "path": path, "version": newVersion})
|
|
}
|
|
|
|
func readableSkillRoots(versionDir, slug string, isSystem bool, bundledDir string) []string {
|
|
var roots []string
|
|
if info, err := os.Stat(versionDir); err == nil && info.IsDir() {
|
|
roots = append(roots, versionDir)
|
|
}
|
|
if isSystem && bundledDir != "" {
|
|
bundledRoot := filepath.Join(bundledDir, slug)
|
|
if info, err := os.Stat(bundledRoot); err == nil && info.IsDir() {
|
|
roots = append(roots, bundledRoot)
|
|
}
|
|
}
|
|
return roots
|
|
}
|
|
|
|
// readSkillFile reads a file with security checks (symlink rejection, artifact filtering).
|
|
// Returns file data, file info, or error.
|
|
func readSkillFile(absPath string) ([]byte, os.FileInfo, error) {
|
|
info, err := os.Lstat(absPath)
|
|
if err != nil || info.IsDir() {
|
|
return nil, nil, os.ErrNotExist
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
slog.Warn("security.skill_files_symlink", "path", absPath)
|
|
return nil, nil, os.ErrPermission
|
|
}
|
|
rel := filepath.Base(absPath)
|
|
if skills.IsSystemArtifact(rel) {
|
|
return nil, nil, os.ErrNotExist
|
|
}
|
|
data, err := os.ReadFile(absPath)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return data, info, nil
|
|
}
|