mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 16:14:57 +00:00
* feat(skills): allow editing SKILL.md content for database-managed skills
Adds the ability to edit a skill's actual SKILL.md content directly
from the web UI, restricted to database/tenant-managed skills only --
bundled/system skills remain read-only.
Backend: new PUT /v1/skills/{id}/files/{path...} endpoint
(handleWriteFile in skills_versions.go), writing to the current
version directory of a non-system skill only (403 for is_system
skills), with the same path-traversal/symlink/ownership guards as the
existing read endpoint. Bumps skill store version and emits
cache-invalidate + audit event on success.
Frontend: Content tab in the skill detail dialog gets an "Edit
content" button, disabled with a tooltip for system skills. Editing
fetches the RAW file content via the existing read endpoint (which
does not strip YAML frontmatter, unlike the stripped preview shown in
the Content tab by default) to avoid silently destroying frontmatter
on save. Save/cancel follow the established catch-and-toast error
pattern (no uncaught promise rejections, matching PR #1346's config
save button fix).
i18n keys added to all 4 locales (en, ko, vi, zh).
* fix(skills): bump version on content edit, fix scroll regression in content view
handleWriteFile previously overwrote the current skill version in place
instead of creating a new immutable version, inconsistent with the
skill_manage tool's patch action. Now creates a new version directory
and updates the DB pointer, matching that convention.
Also fixes a scroll regression in the skill detail dialog's Content
tab introduced by the edit-mode textarea -- both the read-only preview
and edit textarea are now properly scrollable within the dialog.
* fix(skills): refresh version display after save, fix dialog height constraint for scrolling
Version bump now correctly reflects in the UI immediately after save
without requiring a page reload. Fixed the actual root cause of the
scroll issue: the dialog's height wasn't bounded, so overflow-y-auto
on inner content had no effect since nothing constrained the dialog's
total height in the first place.
---------
Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
127 lines
4.2 KiB
Go
127 lines
4.2 KiB
Go
package http
|
|
|
|
import (
|
|
"bytes"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
func TestHandleWriteFile_UpdatesManagedSkillContent(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
versionDir := filepath.Join(baseDir, "skills-store", "my-skill", "1")
|
|
if err := os.MkdirAll(versionDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(versionDir, "SKILL.md"), []byte("old content"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
skillStore := newSkillManageStoreStub(baseDir)
|
|
id := skillStore.seedCustomSkill("my-skill", versionDir, "active", nil)
|
|
handler := NewSkillsHandler(skillStore, baseDir, baseDir, "", bus.New(), nil, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "/v1/skills/"+id.String()+"/files/SKILL.md", bytes.NewBufferString(`{"content":"new content"}`))
|
|
req.SetPathValue("id", id.String())
|
|
req.SetPathValue("path", "SKILL.md")
|
|
req = req.WithContext(store.WithTenantID(req.Context(), store.MasterTenantID))
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.handleWriteFile(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// The original version directory remains untouched (historical versions
|
|
// are immutable).
|
|
origGot, err := os.ReadFile(filepath.Join(versionDir, "SKILL.md"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(origGot) != "old content" {
|
|
t.Fatalf("original version content = %q, want unchanged %q", origGot, "old content")
|
|
}
|
|
|
|
// A new version directory was created with the edited content, and the
|
|
// skill's DB row now points at it with a bumped version number.
|
|
updated, ok := skillStore.skills[id]
|
|
if !ok {
|
|
t.Fatal("skill not found in store after update")
|
|
}
|
|
if updated.Version != 2 {
|
|
t.Fatalf("version = %d, want 2", updated.Version)
|
|
}
|
|
newVersionDir := filepath.Join(baseDir, "skills-store", "my-skill", "2")
|
|
got, err := os.ReadFile(filepath.Join(newVersionDir, "SKILL.md"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(got) != "new content" {
|
|
t.Fatalf("content = %q, want %q", got, "new content")
|
|
}
|
|
}
|
|
|
|
func TestHandleWriteFile_RejectsSystemSkill(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
versionDir := filepath.Join(baseDir, "sys-skill")
|
|
if err := os.MkdirAll(versionDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(versionDir, "SKILL.md"), []byte("original"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
skillStore := newSkillManageStoreStub(baseDir)
|
|
id := skillStore.seedSystemSkill("sys-skill", versionDir)
|
|
handler := NewSkillsHandler(skillStore, baseDir, baseDir, "", bus.New(), nil, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "/v1/skills/"+id.String()+"/files/SKILL.md", bytes.NewBufferString(`{"content":"hacked"}`))
|
|
req.SetPathValue("id", id.String())
|
|
req.SetPathValue("path", "SKILL.md")
|
|
req = req.WithContext(store.WithTenantID(req.Context(), store.MasterTenantID))
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.handleWriteFile(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(versionDir, "SKILL.md"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(got) != "original" {
|
|
t.Fatalf("system skill content changed: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleWriteFile_RejectsPathTraversal(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
versionDir := filepath.Join(baseDir, "my-skill", "1")
|
|
if err := os.MkdirAll(versionDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
skillStore := newSkillManageStoreStub(baseDir)
|
|
id := skillStore.seedCustomSkill("my-skill", versionDir, "active", nil)
|
|
handler := NewSkillsHandler(skillStore, baseDir, baseDir, "", bus.New(), nil, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "/v1/skills/"+id.String()+"/files/../../etc/passwd", bytes.NewBufferString(`{"content":"pwned"}`))
|
|
req.SetPathValue("id", id.String())
|
|
req.SetPathValue("path", "../../etc/passwd")
|
|
req = req.WithContext(store.WithTenantID(req.Context(), store.MasterTenantID))
|
|
rec := httptest.NewRecorder()
|
|
|
|
handler.handleWriteFile(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|