mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 10:10:49 +00:00
a7f5acc1e3
- execMapUpdate: validate column names with strict regex to prevent SQL injection - HTTP update handlers: add field allowlists (agents, providers, custom_tools, mcp, channel_instances) - pqStringArray: properly escape array elements to prevent PostgreSQL array literal injection - scanStringArray: handle quoted elements in PostgreSQL array format - MCP bridge: wrap tool results as external/untrusted content to prevent prompt injection - File serving: block access to sensitive system directories (/etc, /proc, /sys, etc.) - Sandbox: fail closed when Docker unavailable instead of silent fallback to host - Shell deny: fix base64 --decode bypass, add host exec 1MB output limit - ILIKE queries: escape % and _ wildcards in knowledge_graph, custom_tools, channel_instances Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com>
305 lines
11 KiB
Go
305 lines
11 KiB
Go
package http
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// AgentsHandler handles agent CRUD and sharing endpoints.
|
|
type AgentsHandler struct {
|
|
agents store.AgentStore
|
|
token string
|
|
defaultWorkspace string // default workspace path template (e.g. "~/.goclaw/workspace")
|
|
msgBus *bus.MessageBus // for cache invalidation events (nil = no events)
|
|
summoner *AgentSummoner // LLM-based agent setup (nil = disabled)
|
|
isOwner func(string) bool // checks if user ID is a system owner (nil = no owners configured)
|
|
}
|
|
|
|
// NewAgentsHandler creates a handler for agent management endpoints.
|
|
// isOwner is a function that checks if a user ID is in GOCLAW_OWNER_IDS (nil = disabled).
|
|
func NewAgentsHandler(agents store.AgentStore, token, defaultWorkspace string, msgBus *bus.MessageBus, summoner *AgentSummoner, isOwner func(string) bool) *AgentsHandler {
|
|
return &AgentsHandler{agents: agents, token: token, defaultWorkspace: defaultWorkspace, msgBus: msgBus, summoner: summoner, isOwner: isOwner}
|
|
}
|
|
|
|
// isOwnerUser checks if the given user ID is a system owner.
|
|
func (h *AgentsHandler) isOwnerUser(userID string) bool {
|
|
return userID != "" && h.isOwner != nil && h.isOwner(userID)
|
|
}
|
|
|
|
// emitCacheInvalidate broadcasts a cache invalidation event if msgBus is set.
|
|
func (h *AgentsHandler) emitCacheInvalidate(kind, key string) {
|
|
if h.msgBus == nil {
|
|
return
|
|
}
|
|
h.msgBus.Broadcast(bus.Event{
|
|
Name: protocol.EventCacheInvalidate,
|
|
Payload: bus.CacheInvalidatePayload{Kind: kind, Key: key},
|
|
})
|
|
}
|
|
|
|
// RegisterRoutes registers all agent management routes on the given mux.
|
|
func (h *AgentsHandler) RegisterRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /v1/agents", h.authMiddleware(h.handleList))
|
|
mux.HandleFunc("POST /v1/agents", h.authMiddleware(h.handleCreate))
|
|
mux.HandleFunc("GET /v1/agents/{id}", h.authMiddleware(h.handleGet))
|
|
mux.HandleFunc("PUT /v1/agents/{id}", h.authMiddleware(h.handleUpdate))
|
|
mux.HandleFunc("DELETE /v1/agents/{id}", h.authMiddleware(h.handleDelete))
|
|
mux.HandleFunc("GET /v1/agents/{id}/shares", h.authMiddleware(h.handleListShares))
|
|
mux.HandleFunc("POST /v1/agents/{id}/shares", h.authMiddleware(h.handleShare))
|
|
mux.HandleFunc("DELETE /v1/agents/{id}/shares/{userID}", h.authMiddleware(h.handleRevokeShare))
|
|
mux.HandleFunc("POST /v1/agents/{id}/regenerate", h.authMiddleware(h.handleRegenerate))
|
|
mux.HandleFunc("POST /v1/agents/{id}/resummon", h.authMiddleware(h.handleResummon))
|
|
mux.HandleFunc("GET /v1/agents/{id}/instances", h.authMiddleware(h.handleListInstances))
|
|
mux.HandleFunc("GET /v1/agents/{id}/instances/{userID}/files", h.authMiddleware(h.handleGetInstanceFiles))
|
|
mux.HandleFunc("PUT /v1/agents/{id}/instances/{userID}/files/{fileName}", h.authMiddleware(h.handleSetInstanceFile))
|
|
mux.HandleFunc("PATCH /v1/agents/{id}/instances/{userID}/metadata", h.authMiddleware(h.handleUpdateInstanceMetadata))
|
|
}
|
|
|
|
func (h *AgentsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|
return requireAuth(h.token, "", next)
|
|
}
|
|
|
|
func (h *AgentsHandler) handleList(w http.ResponseWriter, r *http.Request) {
|
|
userID := store.UserIDFromContext(r.Context())
|
|
if userID == "" {
|
|
locale := store.LocaleFromContext(r.Context())
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgUserIDHeader)})
|
|
return
|
|
}
|
|
|
|
var agents []store.AgentData
|
|
var err error
|
|
if h.isOwnerUser(userID) {
|
|
agents, err = h.agents.List(r.Context(), "") // owners see all agents
|
|
} else {
|
|
agents, err = h.agents.ListAccessible(r.Context(), userID)
|
|
}
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"agents": agents})
|
|
}
|
|
|
|
func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
|
|
userID := store.UserIDFromContext(r.Context())
|
|
locale := store.LocaleFromContext(r.Context())
|
|
if userID == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgUserIDHeader)})
|
|
return
|
|
}
|
|
|
|
var req store.AgentData
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())})
|
|
return
|
|
}
|
|
|
|
if !isValidSlug(req.AgentKey) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "agent_key")})
|
|
return
|
|
}
|
|
|
|
// Check for duplicate agent_key before creating
|
|
if existing, _ := h.agents.GetByKey(r.Context(), req.AgentKey); existing != nil {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": i18n.T(locale, i18n.MsgAlreadyExists, "agent", req.AgentKey)})
|
|
return
|
|
}
|
|
|
|
req.OwnerID = userID
|
|
if req.AgentType == "" {
|
|
req.AgentType = store.AgentTypeOpen
|
|
}
|
|
if req.ContextWindow <= 0 {
|
|
req.ContextWindow = 200000
|
|
}
|
|
if req.MaxToolIterations <= 0 {
|
|
req.MaxToolIterations = 20
|
|
}
|
|
if req.Workspace == "" {
|
|
req.Workspace = fmt.Sprintf("%s/%s", h.defaultWorkspace, req.AgentKey)
|
|
}
|
|
req.RestrictToWorkspace = true
|
|
|
|
// Default: enable compaction and memory for new agents
|
|
if len(req.CompactionConfig) == 0 {
|
|
req.CompactionConfig = json.RawMessage(`{}`)
|
|
}
|
|
if len(req.MemoryConfig) == 0 {
|
|
req.MemoryConfig = json.RawMessage(`{"enabled":true}`)
|
|
}
|
|
|
|
// Check if predefined agent has a description for LLM summoning
|
|
description := extractDescription(req.OtherConfig)
|
|
if req.AgentType == store.AgentTypePredefined && description != "" && h.summoner != nil {
|
|
req.Status = store.AgentStatusSummoning
|
|
} else if req.Status == "" {
|
|
req.Status = store.AgentStatusActive
|
|
}
|
|
|
|
if err := h.agents.Create(r.Context(), &req); err != nil {
|
|
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "23505") {
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": i18n.T(locale, i18n.MsgAlreadyExists, "agent", req.AgentKey)})
|
|
} else {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return
|
|
}
|
|
|
|
// Seed context files into agent_context_files (skipped for open agents).
|
|
// For summoning agents, templates serve as fallback if LLM fails.
|
|
if _, err := bootstrap.SeedToStore(r.Context(), h.agents, req.ID, req.AgentType); err != nil {
|
|
slog.Warn("failed to seed context files for new agent", "agent", req.AgentKey, "error", err)
|
|
}
|
|
|
|
// Start LLM summoning in background if applicable
|
|
if req.Status == store.AgentStatusSummoning {
|
|
go h.summoner.SummonAgent(req.ID, req.Provider, req.Model, description)
|
|
}
|
|
|
|
emitAudit(h.msgBus, r, "agent.created", "agent", req.ID.String())
|
|
writeJSON(w, http.StatusCreated, req)
|
|
}
|
|
|
|
func (h *AgentsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
|
|
userID := store.UserIDFromContext(r.Context())
|
|
locale := store.LocaleFromContext(r.Context())
|
|
isOwner := h.isOwnerUser(userID)
|
|
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
// Try by agent_key
|
|
ag, err2 := h.agents.GetByKey(r.Context(), r.PathValue("id"))
|
|
if err2 != nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", r.PathValue("id"))})
|
|
return
|
|
}
|
|
if userID != "" && !isOwner {
|
|
if ok, _, _ := h.agents.CanAccess(r.Context(), ag.ID, userID); !ok {
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgNoAccess, "agent")})
|
|
return
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, ag)
|
|
return
|
|
}
|
|
|
|
ag, err := h.agents.GetByID(r.Context(), id)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
|
|
return
|
|
}
|
|
|
|
if userID != "" && !isOwner {
|
|
if ok, _, _ := h.agents.CanAccess(r.Context(), id, userID); !ok {
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgNoAccess, "agent")})
|
|
return
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, ag)
|
|
}
|
|
|
|
func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
|
userID := store.UserIDFromContext(r.Context())
|
|
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, "agent")})
|
|
return
|
|
}
|
|
|
|
// Only owner can update
|
|
ag, err := h.agents.GetByID(r.Context(), id)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
|
|
return
|
|
}
|
|
if userID != "" && ag.OwnerID != userID && !h.isOwnerUser(userID) {
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "update agent")})
|
|
return
|
|
}
|
|
|
|
var updates map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())})
|
|
return
|
|
}
|
|
|
|
// Allowlist: only permit known agent columns to be updated.
|
|
// Defense-in-depth against column injection via arbitrary JSON keys.
|
|
allowed := filterAllowedKeys(updates, agentAllowedFields)
|
|
allowed["restrict_to_workspace"] = true
|
|
|
|
if err := h.agents.Update(r.Context(), id, allowed); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Invalidate caches: agent Loop + bootstrap files
|
|
h.emitCacheInvalidate(bus.CacheKindAgent, ag.AgentKey)
|
|
h.emitCacheInvalidate(bus.CacheKindBootstrap, id.String())
|
|
|
|
// Cascade: if status changed, broadcast so channel instances and cron jobs react.
|
|
if newStatus, ok := allowed["status"].(string); ok && newStatus != ag.Status {
|
|
if h.msgBus != nil {
|
|
h.msgBus.Broadcast(bus.Event{
|
|
Name: bus.EventAgentStatusChanged,
|
|
Payload: bus.AgentStatusChangedPayload{
|
|
AgentID: id.String(),
|
|
OldStatus: ag.Status,
|
|
NewStatus: newStatus,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
emitAudit(h.msgBus, r, "agent.updated", "agent", id.String())
|
|
writeJSON(w, http.StatusOK, map[string]string{"ok": "true"})
|
|
}
|
|
|
|
func (h *AgentsHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
|
|
userID := store.UserIDFromContext(r.Context())
|
|
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, "agent")})
|
|
return
|
|
}
|
|
|
|
// Only owner can delete
|
|
ag, err := h.agents.GetByID(r.Context(), id)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", id.String())})
|
|
return
|
|
}
|
|
if userID != "" && ag.OwnerID != userID && !h.isOwnerUser(userID) {
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgOwnerOnly, "delete agent")})
|
|
return
|
|
}
|
|
|
|
if err := h.agents.Delete(r.Context(), id); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Invalidate caches: agent Loop + bootstrap files
|
|
h.emitCacheInvalidate(bus.CacheKindAgent, ag.AgentKey)
|
|
h.emitCacheInvalidate(bus.CacheKindBootstrap, id.String())
|
|
|
|
emitAudit(h.msgBus, r, "agent.deleted", "agent", id.String())
|
|
writeJSON(w, http.StatusOK, map[string]string{"ok": "true"})
|
|
}
|