mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-18 00:47:44 +00:00
690 lines
28 KiB
Go
690 lines
28 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// AgentsHandler handles agent CRUD and sharing endpoints.
|
|
type AgentsHandler struct {
|
|
agents store.AgentStore
|
|
providers store.ProviderStore
|
|
providerReg *providers.Registry
|
|
db *sql.DB
|
|
tracingStore store.TracingStore
|
|
memoryStore store.MemoryStore // for import (nil = disabled)
|
|
kgStore store.KnowledgeGraphStore // for import (nil = disabled)
|
|
episodicStore store.EpisodicStore // for import (nil in SQLite/lite builds)
|
|
vaultStore store.VaultStore // for vault import (nil = disabled)
|
|
toolsReg ToolPreviewLister // for system prompt preview tool resolution (nil = fallback)
|
|
skillsLoader SkillPreviewBuilder // for system prompt preview pinned skills (nil = skip)
|
|
skillAccessStore store.SkillAccessStore // for system prompt preview skill filtering (nil = skip)
|
|
teamStore store.TeamStore // for system prompt preview team context (nil = skip)
|
|
agentLinkStore store.AgentLinkStore // for system prompt preview delegation targets (nil = skip)
|
|
secureCLI store.SecureCLIStore
|
|
secureCLIGrants store.SecureCLIAgentGrantStore
|
|
secureCLIAgentCreds store.SecureCLIAgentCredentialStore
|
|
defaultWorkspace string // default workspace path template (e.g. "~/.goclaw/workspace")
|
|
dataDir string // resolved data directory (e.g. "~/.goclaw/data") — for team workspace export
|
|
gatewayAddr string
|
|
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)
|
|
findGatewayOperatorBinary func() (string, error)
|
|
}
|
|
|
|
// 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, providers store.ProviderStore, providerReg *providers.Registry, db *sql.DB, tracing store.TracingStore, defaultWorkspace string, msgBus *bus.MessageBus, summoner *AgentSummoner, isOwner func(string) bool) *AgentsHandler {
|
|
return &AgentsHandler{
|
|
agents: agents,
|
|
providers: providers,
|
|
providerReg: providerReg,
|
|
db: db,
|
|
tracingStore: tracing,
|
|
defaultWorkspace: defaultWorkspace,
|
|
msgBus: msgBus,
|
|
summoner: summoner,
|
|
isOwner: isOwner,
|
|
}
|
|
}
|
|
|
|
// SetDataDir sets the resolved data directory used for team workspace paths.
|
|
func (h *AgentsHandler) SetDataDir(dataDir string) {
|
|
h.dataDir = dataDir
|
|
}
|
|
|
|
// SetImportStores attaches optional stores needed for agent import.
|
|
func (h *AgentsHandler) SetImportStores(mem store.MemoryStore, kg store.KnowledgeGraphStore) {
|
|
h.memoryStore = mem
|
|
h.kgStore = kg
|
|
}
|
|
|
|
// SetEpisodicStore attaches the episodic store for Tier 2 memory import.
|
|
// Not available in SQLite/lite builds — nil is safe (episodic import is skipped).
|
|
func (h *AgentsHandler) SetEpisodicStore(ep store.EpisodicStore) {
|
|
h.episodicStore = ep
|
|
}
|
|
|
|
// SetVaultStore attaches the vault store for Knowledge Vault import.
|
|
// nil is safe — vault import is skipped when not set.
|
|
func (h *AgentsHandler) SetVaultStore(vs store.VaultStore) {
|
|
h.vaultStore = vs
|
|
}
|
|
|
|
// ToolPreviewLister is satisfied by tools.Registry for system prompt preview.
|
|
type ToolPreviewLister interface {
|
|
List() []string
|
|
Get(name string) (tools.Tool, bool)
|
|
Aliases() map[string]string
|
|
}
|
|
|
|
// SkillPreviewBuilder is satisfied by skills.Loader for system prompt preview.
|
|
type SkillPreviewBuilder interface {
|
|
BuildPinnedSummary(ctx context.Context, names []string) string
|
|
BuildSummary(ctx context.Context, allowList []string) string
|
|
}
|
|
|
|
// SetPreviewDeps attaches optional dependencies for system prompt preview.
|
|
func (h *AgentsHandler) SetPreviewDeps(tl ToolPreviewLister, sl SkillPreviewBuilder) {
|
|
h.toolsReg = tl
|
|
h.skillsLoader = sl
|
|
}
|
|
|
|
// SetPreviewStores attaches team + agent link stores for system prompt preview.
|
|
func (h *AgentsHandler) SetPreviewStores(ts store.TeamStore, als store.AgentLinkStore, sas store.SkillAccessStore) {
|
|
h.teamStore = ts
|
|
h.agentLinkStore = als
|
|
h.skillAccessStore = sas
|
|
}
|
|
|
|
// 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) {
|
|
// Agent CRUD (reads: viewer+, writes: admin+)
|
|
mux.HandleFunc("GET /v1/agents", h.authMiddleware(h.handleList))
|
|
mux.HandleFunc("POST /v1/agents", h.adminMiddleware(h.handleCreate))
|
|
mux.HandleFunc("GET /v1/agents/{id}", h.authMiddleware(h.handleGet))
|
|
// Finding #15: PUT /v1/agents/{id} is gated by adminMiddleware (RoleAdmin required).
|
|
// Admin-only access significantly reduces abuse risk — rapid writes by a malicious admin
|
|
// are an insider threat with broader capabilities than tts_params mutation.
|
|
// No additional per-user rate limiter is added at this time (YAGNI). Re-evaluate
|
|
// if non-admin write paths are ever added or the endpoint is exposed via OAuth scopes.
|
|
mux.HandleFunc("PUT /v1/agents/{id}", h.adminMiddleware(h.handleUpdate))
|
|
mux.HandleFunc("DELETE /v1/agents/{id}", h.adminMiddleware(h.handleDelete))
|
|
// Bulk operations (admin+)
|
|
mux.HandleFunc("POST /v1/agents/sync-workspace", h.adminMiddleware(h.handleSyncWorkspace))
|
|
// Sharing (admin+)
|
|
mux.HandleFunc("GET /v1/agents/{id}/shares", h.authMiddleware(h.handleListShares))
|
|
mux.HandleFunc("POST /v1/agents/{id}/shares", h.adminMiddleware(h.handleShare))
|
|
mux.HandleFunc("DELETE /v1/agents/{id}/shares/{userID}", h.adminMiddleware(h.handleRevokeShare))
|
|
// Agent operations (admin+)
|
|
mux.HandleFunc("POST /v1/agents/{id}/regenerate", h.adminMiddleware(h.handleRegenerate))
|
|
mux.HandleFunc("POST /v1/agents/{id}/resummon", h.adminMiddleware(h.handleResummon))
|
|
mux.HandleFunc("POST /v1/agents/{id}/cancel-summon", h.adminMiddleware(h.handleCancelSummon))
|
|
// Export (agent owner or system owner)
|
|
mux.HandleFunc("GET /v1/agents/{id}/system-prompt-preview", h.adminMiddleware(h.handleSystemPromptPreview))
|
|
mux.HandleFunc("GET /v1/agents/{id}/export/preview", h.authMiddleware(h.handleExportPreview))
|
|
mux.HandleFunc("GET /v1/agents/{id}/export", h.authMiddleware(h.handleExport))
|
|
mux.HandleFunc("GET /v1/agents/{id}/export/download/{token}", h.authMiddleware(h.handleExportDownload))
|
|
// Shared download route for all export types (skills, MCP, teams use same token map)
|
|
mux.HandleFunc("GET /v1/export/download/{token}", h.authMiddleware(h.handleExportDownload))
|
|
// Import (admin only — system owner or tenant admin)
|
|
mux.HandleFunc("POST /v1/agents/import/preview", h.adminMiddleware(h.handleImportPreview))
|
|
mux.HandleFunc("POST /v1/agents/import", h.adminMiddleware(h.handleImport))
|
|
mux.HandleFunc("POST /v1/agents/{id}/import", h.adminMiddleware(h.handleMergeImport))
|
|
// Team export/import (system owner only)
|
|
mux.HandleFunc("GET /v1/teams/{id}/export/preview", h.adminMiddleware(h.handleTeamExportPreview))
|
|
mux.HandleFunc("GET /v1/teams/{id}/export", h.adminMiddleware(h.handleTeamExport))
|
|
mux.HandleFunc("POST /v1/teams/import", h.adminMiddleware(h.handleTeamImport))
|
|
// Read-only (viewer+)
|
|
mux.HandleFunc("GET /v1/agents/{id}/codex-pool-activity", h.authMiddleware(h.handleCodexPoolActivity))
|
|
mux.HandleFunc("GET /v1/agents/{id}/instances", h.authMiddleware(h.handleListInstances))
|
|
mux.HandleFunc("GET /v1/agents/{id}/instances/{userID}/files", h.authMiddleware(h.handleGetInstanceFiles))
|
|
// Instance writes (admin+)
|
|
mux.HandleFunc("PUT /v1/agents/{id}/instances/{userID}/files/{fileName}", h.adminMiddleware(h.handleSetInstanceFile))
|
|
mux.HandleFunc("PATCH /v1/agents/{id}/instances/{userID}/metadata", h.adminMiddleware(h.handleUpdateInstanceMetadata))
|
|
}
|
|
|
|
func (h *AgentsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|
return requireAuth("", next)
|
|
}
|
|
|
|
func (h *AgentsHandler) adminMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|
return requireAuth(permissions.RoleAdmin, next)
|
|
}
|
|
|
|
func (h *AgentsHandler) handleList(w http.ResponseWriter, r *http.Request) {
|
|
userID := store.UserIDFromContext(r.Context())
|
|
|
|
var agents []store.AgentData
|
|
var err error
|
|
switch {
|
|
case userID == "" && permissions.HasMinRole(permissions.Role(store.RoleFromContext(r.Context())), permissions.RoleAdmin):
|
|
agents, err = h.agents.List(r.Context(), "")
|
|
case userID == "":
|
|
locale := store.LocaleFromContext(r.Context())
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgUserIDHeader))
|
|
return
|
|
case h.isOwnerUser(userID):
|
|
agents, err = h.agents.List(r.Context(), "") // owners see all agents
|
|
default:
|
|
agents, err = h.agents.ListAccessible(r.Context(), userID)
|
|
}
|
|
if err != nil {
|
|
slog.Error("agents.list", "error", err)
|
|
locale := store.LocaleFromContext(r.Context())
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "agents"))
|
|
return
|
|
}
|
|
|
|
publicAgents := make([]store.AgentData, 0, len(agents))
|
|
for i := range agents {
|
|
publicAgents = append(publicAgents, canonicalizeAgentForResponse(&agents[i]))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"agents": publicAgents})
|
|
}
|
|
|
|
func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
|
|
userID := store.UserIDFromContext(r.Context())
|
|
locale := store.LocaleFromContext(r.Context())
|
|
if userID == "" {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgUserIDHeader))
|
|
return
|
|
}
|
|
|
|
var createReq struct {
|
|
store.AgentData
|
|
GrantGatewayOperatorAccess bool `json:"grant_gateway_operator_access,omitempty"`
|
|
}
|
|
if !bindJSON(w, r, locale, &createReq) {
|
|
return
|
|
}
|
|
req := createReq.AgentData
|
|
|
|
if !isValidSlug(req.AgentKey) {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, 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 {
|
|
writeError(w, http.StatusConflict, protocol.ErrAlreadyExists, i18n.T(locale, i18n.MsgAlreadyExists, "agent", req.AgentKey))
|
|
return
|
|
}
|
|
|
|
req.OwnerID = userID
|
|
|
|
// Resolve tenant_id: explicit body field for cross-tenant; otherwise inherit from auth context.
|
|
if store.IsOwnerRole(r.Context()) {
|
|
if req.TenantID == uuid.Nil {
|
|
req.TenantID = store.TenantIDFromContext(r.Context())
|
|
}
|
|
} else {
|
|
req.TenantID = store.TenantIDFromContext(r.Context())
|
|
}
|
|
|
|
if req.AgentType == "" || req.AgentType == store.AgentTypeOpen {
|
|
req.AgentType = store.AgentTypePredefined // v3: open agents deprecated, default to predefined
|
|
}
|
|
if req.ContextWindow <= 0 {
|
|
req.ContextWindow = config.DefaultContextWindow
|
|
}
|
|
if req.MaxToolIterations <= 0 {
|
|
req.MaxToolIterations = config.DefaultMaxIterations
|
|
}
|
|
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 := req.AgentDescription
|
|
if req.AgentType == store.AgentTypePredefined && description != "" && h.summoner != nil {
|
|
req.Status = store.AgentStatusSummoning
|
|
} else if req.Status == "" {
|
|
req.Status = store.AgentStatusActive
|
|
}
|
|
|
|
if err := validateChatGPTOAuthAgentRouting(
|
|
r.Context(),
|
|
h.providers,
|
|
req.Provider,
|
|
req.ParseChatGPTOAuthRouting(),
|
|
); err != nil {
|
|
slog.Error("agents.create.validate_routing", "error", err)
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, err.Error()))
|
|
return
|
|
}
|
|
if err := validateAgentModelFallback(req.ModelFallback); err != nil {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, err.Error()))
|
|
return
|
|
}
|
|
|
|
if err := h.agents.Create(r.Context(), &req); err != nil {
|
|
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "23505") {
|
|
writeError(w, http.StatusConflict, protocol.ErrAlreadyExists, i18n.T(locale, i18n.MsgAlreadyExists, "agent", req.AgentKey))
|
|
} else {
|
|
slog.Error("agents.create", "agent_key", req.AgentKey, "error", err)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "agent", "internal 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.TenantID, req.Provider, req.Model, description)
|
|
}
|
|
|
|
emitAudit(h.msgBus, r, "agent.created", "agent", req.ID.String())
|
|
publicAgent := canonicalizeAgentForResponse(&req)
|
|
if createReq.GrantGatewayOperatorAccess {
|
|
writeJSON(w, http.StatusCreated, struct {
|
|
store.AgentData
|
|
GatewayOperatorBootstrap *gatewayOperatorBootstrapResult `json:"gateway_operator_bootstrap,omitempty"`
|
|
}{
|
|
AgentData: publicAgent,
|
|
GatewayOperatorBootstrap: h.bootstrapGatewayOperatorForCreatedAgent(r.Context(), req.ID, locale),
|
|
})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, publicAgent)
|
|
}
|
|
|
|
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 {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "agent", r.PathValue("id")))
|
|
return
|
|
}
|
|
if userID != "" && !isOwner {
|
|
if ok, _, _ := h.agents.CanAccess(r.Context(), ag.ID, userID); !ok {
|
|
writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgNoAccess, "agent"))
|
|
return
|
|
}
|
|
}
|
|
publicAgent := canonicalizeAgentForResponse(ag)
|
|
writeJSON(w, http.StatusOK, publicAgent)
|
|
return
|
|
}
|
|
|
|
ag, err := h.agents.GetByID(r.Context(), id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "agent", id.String()))
|
|
return
|
|
}
|
|
|
|
if userID != "" && !isOwner {
|
|
if ok, _, _ := h.agents.CanAccess(r.Context(), id, userID); !ok {
|
|
writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgNoAccess, "agent"))
|
|
return
|
|
}
|
|
}
|
|
|
|
publicAgent := canonicalizeAgentForResponse(ag)
|
|
writeJSON(w, http.StatusOK, publicAgent)
|
|
}
|
|
|
|
func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
|
// Finding #6: cap request body to 64 KB — prevents heap pressure from
|
|
// malicious large payloads stored in JSONB fields like tts_params.
|
|
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
|
|
|
userID := store.UserIDFromContext(r.Context())
|
|
locale := store.LocaleFromContext(r.Context())
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "agent"))
|
|
return
|
|
}
|
|
|
|
// Tenant admins can update any agent in their tenant (adminMiddleware already
|
|
// verified RoleAdmin). System owners can update any agent across tenants.
|
|
// GetByID respects tenant scoping from context, so if the agent is returned
|
|
// it belongs to the caller's tenant.
|
|
ag, err := h.agents.GetByID(r.Context(), id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "agent", id.String()))
|
|
return
|
|
}
|
|
|
|
// Finding #12: explicit tenant-scope guard as defense-in-depth.
|
|
// GetByID already scopes by tenant_id from context, but if a future refactor
|
|
// swaps to an unscoped variant this guard prevents cross-tenant mutation.
|
|
if ag.TenantID != store.TenantIDFromContext(r.Context()) {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "agent", id.String()))
|
|
return
|
|
}
|
|
|
|
var updates map[string]any
|
|
if !bindJSON(w, r, locale, &updates) {
|
|
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 agent_key is being changed, enforce the slug format. The router
|
|
// cache uses `tenantID:agentKey` as its canonical key and splits on the
|
|
// last colon for exact-segment invalidation — a colon inside agent_key
|
|
// would silently break invalidation. Slug regex already rejects colons
|
|
// and any other shell/path-unfriendly characters.
|
|
if newKey, ok := allowed["agent_key"].(string); ok && newKey != "" {
|
|
if !isValidSlug(newKey) {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidSlug, "agent_key"))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Validate v3 flag values in other_config (must be boolean).
|
|
// Also validate tts_params allow-list (Finding #5).
|
|
if oc, ok := allowed["other_config"]; ok && oc != nil {
|
|
switch v := oc.(type) {
|
|
case map[string]any:
|
|
if err := store.ValidateV3Flags(v); err != nil {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, err.Error())
|
|
return
|
|
}
|
|
// Finding #5: enforce tts_params key allow-list so arbitrary keys
|
|
// (e.g. __proto__, voice_settings.stability) cannot persist in JSONB.
|
|
if tp, ok := v["tts_params"]; ok && tp != nil {
|
|
if tpMap, ok := tp.(map[string]any); ok {
|
|
if err := validateAgentTTSParams(tpMap); err != nil {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, err.Error())
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
validationProvider := ag.Provider
|
|
if providerName, ok := allowed["provider"].(string); ok && providerName != "" {
|
|
validationProvider = providerName
|
|
}
|
|
validationAgent := *ag
|
|
validationAgent.Provider = validationProvider
|
|
if otherConfig, ok := allowed["other_config"]; ok {
|
|
rawOtherConfig, err := marshalJSONRaw(otherConfig)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON))
|
|
return
|
|
}
|
|
validationAgent.OtherConfig = rawOtherConfig
|
|
}
|
|
if routing, ok := allowed["chatgpt_oauth_routing"]; ok {
|
|
rawRouting, err := marshalJSONRaw(routing)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON))
|
|
return
|
|
}
|
|
validationAgent.ChatGPTOAuthRouting = rawRouting
|
|
allowed["chatgpt_oauth_routing"] = rawRouting
|
|
}
|
|
if fallback, ok := allowed["model_fallback"]; ok {
|
|
rawFallback, err := marshalJSONRaw(fallback)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON))
|
|
return
|
|
}
|
|
if err := validateAgentModelFallback(rawFallback); err != nil {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, err.Error()))
|
|
return
|
|
}
|
|
validationAgent.ModelFallback = rawFallback
|
|
allowed["model_fallback"] = rawFallback
|
|
}
|
|
|
|
if err := validateChatGPTOAuthAgentRouting(
|
|
r.Context(),
|
|
h.providers,
|
|
validationAgent.Provider,
|
|
validationAgent.ParseChatGPTOAuthRouting(),
|
|
); err != nil {
|
|
slog.Error("agents.update.validate_routing", "id", id, "error", err)
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, err.Error()))
|
|
return
|
|
}
|
|
|
|
if err := h.agents.Update(r.Context(), id, allowed); err != nil {
|
|
slog.Error("agents.update", "id", id, "user_id", userID,
|
|
"tenant_id", store.TenantIDFromContext(r.Context()), "error", err)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToUpdate, "agent", err.Error()))
|
|
return
|
|
}
|
|
|
|
// Sync display_name change into IDENTITY.md so the agent self-reports the new name.
|
|
if newName, ok := allowed["display_name"].(string); ok && newName != "" {
|
|
h.syncIdentityName(r.Context(), ag, newName)
|
|
}
|
|
|
|
// 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 {
|
|
bus.BroadcastForTenant(h.msgBus, bus.EventAgentStatusChanged,
|
|
store.TenantIDFromContext(r.Context()),
|
|
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 validateAgentModelFallback(raw json.RawMessage) error {
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return nil
|
|
}
|
|
var cfg store.ModelFallbackConfig
|
|
if err := json.Unmarshal(raw, &cfg); err != nil {
|
|
return fmt.Errorf("invalid model_fallback")
|
|
}
|
|
normalized := store.NormalizeModelFallbackConfig(&cfg)
|
|
if normalized == nil {
|
|
return nil
|
|
}
|
|
for _, candidate := range normalized.Candidates {
|
|
if candidate.Provider == "" || candidate.Model == "" {
|
|
return fmt.Errorf("fallback candidates require provider and model")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// syncIdentityName updates the Name: field in the agent's IDENTITY.md (agent-level and
|
|
// all per-user copies for open agents) so the agent self-reports the new display name.
|
|
// Errors are logged but do not fail the rename request.
|
|
func (h *AgentsHandler) syncIdentityName(ctx context.Context, ag *store.AgentData, newName string) {
|
|
// Read existing agent-level IDENTITY.md.
|
|
existingContent := ""
|
|
if dbFiles, err := h.agents.GetAgentContextFiles(ctx, ag.ID); err == nil {
|
|
for _, f := range dbFiles {
|
|
if f.FileName == bootstrap.IdentityFile {
|
|
existingContent = f.Content
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
newContent := bootstrap.UpdateIdentityField(existingContent, "Name", newName)
|
|
if newContent == "" {
|
|
newContent = "# Identity\nName: " + newName + "\n"
|
|
}
|
|
if err := h.agents.SetAgentContextFile(ctx, ag.ID, bootstrap.IdentityFile, newContent); err != nil {
|
|
slog.Warn("agents.update: failed to sync IDENTITY.md name", "agent", ag.AgentKey, "error", err)
|
|
}
|
|
|
|
// For open agents, also update per-user IDENTITY.md copies.
|
|
if ag.AgentType == store.AgentTypeOpen {
|
|
if userFiles, err := h.agents.ListUserContextFilesByName(ctx, ag.ID, bootstrap.IdentityFile); err == nil {
|
|
for _, uf := range userFiles {
|
|
updated := bootstrap.UpdateIdentityField(uf.Content, "Name", newName)
|
|
if updated == uf.Content {
|
|
continue
|
|
}
|
|
if err := h.agents.SetUserContextFile(ctx, ag.ID, uf.UserID, bootstrap.IdentityFile, updated); err != nil {
|
|
slog.Warn("agents.update: failed to sync user IDENTITY.md name", "agent", ag.AgentKey, "user", uf.UserID, "error", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "agent"))
|
|
return
|
|
}
|
|
|
|
// Only owner can delete
|
|
ag, err := h.agents.GetByID(r.Context(), id)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "agent", id.String()))
|
|
return
|
|
}
|
|
if userID != "" && ag.OwnerID != userID && !h.isOwnerUser(userID) {
|
|
writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgOwnerOnly, "delete agent"))
|
|
return
|
|
}
|
|
|
|
if err := h.agents.Delete(r.Context(), id); err != nil {
|
|
slog.Error("agents.delete", "id", id, "error", err)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToDelete, "agent", "internal 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"})
|
|
}
|
|
|
|
// handleSyncWorkspace updates all agents to use the new workspace root.
|
|
// POST /v1/agents/sync-workspace
|
|
// Body: {"workspace": "E:\\project\\workspace"}
|
|
// Requires admin role.
|
|
func (h *AgentsHandler) handleSyncWorkspace(w http.ResponseWriter, r *http.Request) {
|
|
tenantID := store.TenantIDFromContext(r.Context())
|
|
|
|
var req struct {
|
|
Workspace string `json:"workspace"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, "invalid JSON body")
|
|
return
|
|
}
|
|
if req.Workspace == "" {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, "workspace is required")
|
|
return
|
|
}
|
|
// Path sanity check: reject traversal attempts
|
|
if strings.Contains(req.Workspace, "..") {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, "workspace path cannot contain '..'")
|
|
return
|
|
}
|
|
|
|
// List all agents (empty ownerID = all agents)
|
|
agents, err := h.agents.List(r.Context(), "")
|
|
if err != nil {
|
|
slog.Error("agents.sync_workspace: list failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, "failed to list agents")
|
|
return
|
|
}
|
|
|
|
// Update each agent's workspace to use the new root
|
|
newWorkspace := config.ExpandHome(req.Workspace)
|
|
var updated int
|
|
for _, ag := range agents {
|
|
// Skip agents from other tenants
|
|
if ag.TenantID != tenantID {
|
|
continue
|
|
}
|
|
// Build new workspace path: {newWorkspace}/{agentKey}
|
|
newPath := filepath.Join(newWorkspace, ag.AgentKey)
|
|
if ag.Workspace == newPath {
|
|
continue // already using correct path
|
|
}
|
|
// Use Update with map[string]any
|
|
if err := h.agents.Update(r.Context(), ag.ID, map[string]any{"workspace": newPath}); err != nil {
|
|
slog.Warn("agents.sync_workspace: update failed", "agent", ag.AgentKey, "error", err)
|
|
continue
|
|
}
|
|
h.emitCacheInvalidate(bus.CacheKindAgent, ag.AgentKey)
|
|
updated++
|
|
}
|
|
|
|
slog.Info("agents.sync_workspace: completed", "updated", updated, "total", len(agents), "workspace", newWorkspace)
|
|
emitAudit(h.msgBus, r, "agents.workspace_synced", "updated", strconv.Itoa(updated))
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "updated": updated})
|
|
}
|