mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-18 22:17:10 +00:00
Adds skill_manage — a first-class agent tool for creating, updating, and deleting skills from within a conversation — paired with per-agent skill_evolve that nudges predefined agents to capture reusable workflows. Tool (skill_manage): - create: write skill from SKILL.md content string (auto-grant, dep scan) - patch: find/replace producing new immutable version (advisory-locked) - delete: soft-delete (archive in DB, move to .trash/) - Security guard: 25 regex patterns block shell injection, credential exfil, path traversal, SQL injection, privilege escalation - Ownership enforced: only skill owner can patch/delete (admin bypass) - Content size limit: 100KB; companion file copy: 20MB, symlink-safe (WalkDir) - Enabled: false by default — admin opt-in per agent Learning loop (skill_evolve, predefined agents only): - System prompt: SHOULD/SHOULD NOT guidance for skill creation - Budget nudges: [System] prefix at 70%/90% iteration budget (ephemeral, i18n) - Postscript: once-per-run suggestion with explicit user consent - Config: other_config.skill_evolve + skill_nudge_interval (default 15) Security hardening (pre-existing + new): - CreateSkillManaged: RETURNING id + pg_advisory_xact_lock (atomic upsert) - GetNextVersionLocked: advisory lock for race-safe patch versioning - Ownership checks on HTTP update/delete, gateway update, 4 grant/revoke handlers - copyOtherFiles: filepath.WalkDir for real symlink detection UI: Skill Learning toggle + nudge interval in Agent General Tab i18n: backend (en/vi/zh catalogs) + frontend (en/vi/zh locale files)
194 lines
5.8 KiB
Go
194 lines
5.8 KiB
Go
package methods
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// skillOwnerGetter is an optional interface for stores that can return a skill's owner ID.
|
|
type skillOwnerGetter interface {
|
|
GetSkillOwnerID(id uuid.UUID) (string, bool)
|
|
}
|
|
|
|
// SkillsMethods handles skills.list, skills.get, skills.update.
|
|
type SkillsMethods struct {
|
|
store store.SkillStore
|
|
}
|
|
|
|
func NewSkillsMethods(s store.SkillStore) *SkillsMethods {
|
|
return &SkillsMethods{store: s}
|
|
}
|
|
|
|
func (m *SkillsMethods) Register(router *gateway.MethodRouter) {
|
|
router.Register(protocol.MethodSkillsList, m.handleList)
|
|
router.Register(protocol.MethodSkillsGet, m.handleGet)
|
|
router.Register(protocol.MethodSkillsUpdate, m.handleUpdate)
|
|
}
|
|
|
|
func (m *SkillsMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
allSkills := m.store.ListSkills()
|
|
|
|
result := make([]map[string]any, 0, len(allSkills))
|
|
for _, s := range allSkills {
|
|
entry := map[string]any{
|
|
"name": s.Name,
|
|
"slug": s.Slug,
|
|
"description": s.Description,
|
|
"source": s.Source,
|
|
"version": s.Version,
|
|
"is_system": s.IsSystem,
|
|
"enabled": s.Enabled,
|
|
}
|
|
if s.ID != "" {
|
|
entry["id"] = s.ID
|
|
}
|
|
if s.Visibility != "" {
|
|
entry["visibility"] = s.Visibility
|
|
}
|
|
if len(s.Tags) > 0 {
|
|
entry["tags"] = s.Tags
|
|
}
|
|
if s.Status != "" {
|
|
entry["status"] = s.Status
|
|
}
|
|
if s.Author != "" {
|
|
entry["author"] = s.Author
|
|
}
|
|
if len(s.MissingDeps) > 0 {
|
|
entry["missing_deps"] = s.MissingDeps
|
|
}
|
|
result = append(result, entry)
|
|
}
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
|
"skills": result,
|
|
}))
|
|
}
|
|
|
|
func (m *SkillsMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
var params struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if req.Params != nil {
|
|
json.Unmarshal(req.Params, ¶ms)
|
|
}
|
|
if params.Name == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name")))
|
|
return
|
|
}
|
|
|
|
info, ok := m.store.GetSkill(params.Name)
|
|
if !ok {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "skill", params.Name)))
|
|
return
|
|
}
|
|
|
|
content, _ := m.store.LoadSkill(params.Name)
|
|
|
|
resp := map[string]any{
|
|
"name": info.Name,
|
|
"slug": info.Slug,
|
|
"description": info.Description,
|
|
"source": info.Source,
|
|
"content": content,
|
|
"version": info.Version,
|
|
}
|
|
if info.ID != "" {
|
|
resp["id"] = info.ID
|
|
}
|
|
if info.Visibility != "" {
|
|
resp["visibility"] = info.Visibility
|
|
}
|
|
if len(info.Tags) > 0 {
|
|
resp["tags"] = info.Tags
|
|
}
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, resp))
|
|
}
|
|
|
|
// skillUpdater is an optional interface for stores that support skill updates (e.g. PGSkillStore).
|
|
type skillUpdater interface {
|
|
UpdateSkill(id uuid.UUID, updates map[string]any) error
|
|
}
|
|
|
|
func (m *SkillsMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
var params struct {
|
|
Name string `json:"name"`
|
|
ID string `json:"id"`
|
|
Updates map[string]any `json:"updates"`
|
|
}
|
|
if req.Params != nil {
|
|
json.Unmarshal(req.Params, ¶ms)
|
|
}
|
|
if params.Name == "" && params.ID == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name or id")))
|
|
return
|
|
}
|
|
|
|
// Check if the store supports updates (PGSkillStore does, FileSkillStore doesn't)
|
|
updater, ok := m.store.(skillUpdater)
|
|
if !ok {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgSkillsUpdateNotSupported)))
|
|
return
|
|
}
|
|
|
|
// Resolve skill ID
|
|
var skillID uuid.UUID
|
|
if params.ID != "" {
|
|
parsed, err := uuid.Parse(params.ID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "skill")))
|
|
return
|
|
}
|
|
skillID = parsed
|
|
} else {
|
|
// Look up by name — use GetSkill which returns path info, but we need DB ID
|
|
// For PGSkillStore, the name is the slug
|
|
info, exists := m.store.GetSkill(params.Name)
|
|
if !exists {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "skill", params.Name)))
|
|
return
|
|
}
|
|
// Try to parse Path as UUID (PGSkillStore stores DB ID in Path field for managed skills)
|
|
parsed, err := uuid.Parse(info.Path)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgCannotResolveSkillID)))
|
|
return
|
|
}
|
|
skillID = parsed
|
|
}
|
|
|
|
if params.Updates == nil || len(params.Updates) == 0 {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "updates")))
|
|
return
|
|
}
|
|
|
|
// Ownership check: only skill owner or admin can update
|
|
if !permissions.HasMinRole(client.Role(), permissions.RoleAdmin) {
|
|
if ownerGetter, ok := m.store.(skillOwnerGetter); ok {
|
|
if ownerID, found := ownerGetter.GetSkillOwnerID(skillID); found && ownerID != client.UserID() {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "skills.update")))
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := updater.UpdateSkill(skillID, params.Updates); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
|
|
return
|
|
}
|
|
|
|
m.store.BumpVersion()
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]string{"ok": "true"}))
|
|
}
|