mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 10:13:37 +00:00
Creating a predefined agent with a description kicks off summoning in a
background goroutine. It finishes 10-20 seconds after the create response and
writes SOUL.md, IDENTITY.md, CAPABILITIES.md and the agent frontmatter.
For a client that manages agents as code that is data loss. Such a client
brings its own context files and writes them through agents.files.set as soon
as create returns; summoning lands afterwards and replaces them. Nothing
fails: no error from the API, nothing in the logs, and the agent quietly runs
on generated text instead of the committed role. The only way to avoid it
today is to poll the agent status until it leaves "summoning" — which means
every automation has to know that summoning exists at all.
POST /v1/agents now accepts an optional "summon" field. Absent or true keeps
today's behaviour, so existing clients are unaffected. False creates the agent
active with the seeded template files and starts no LLM call; summoning is
still available afterwards via POST /v1/agents/{id}/resummon.
The status fallback also now resets an explicitly requested "summoning" status
when nothing will summon the agent: that status is only ever cleared by the
summoner, so such an agent would otherwise stay stuck in it forever.
Surface parity: CLI and WS RPC (agents.create) create agents without a
description and never summoned, so they need no flag; the web UI create flow
wants summoning and keeps the default.
743 lines
30 KiB
Go
743 lines
30 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/agent"
|
|
"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/skills"
|
|
"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)
|
|
toolPE *tools.PolicyEngine // for system prompt preview tool policy resolution (nil = skip policy filtering)
|
|
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)
|
|
mcpPreviewMgr agent.MCPPreviewLister // for store-based MCP tool preview (nil = skip)
|
|
disabledToolsStore store.BuiltinToolTenantConfigStore // for per-tenant disabled tool filtering in preview (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
|
|
FilterSkills(ctx context.Context, allowList []string) []skills.Info
|
|
}
|
|
|
|
// SetPreviewDeps attaches optional dependencies for system prompt preview.
|
|
func (h *AgentsHandler) SetPreviewDeps(tl ToolPreviewLister, sl SkillPreviewBuilder) {
|
|
h.toolsReg = tl
|
|
h.skillsLoader = sl
|
|
}
|
|
|
|
// SetPreviewToolPolicy attaches the gateway's PolicyEngine so system prompt
|
|
// preview applies the same full allow/deny/alsoAllow resolution (including
|
|
// global deny) as the live chat loop. nil is safe — preview falls back to
|
|
// the tool list unfiltered by policy.
|
|
func (h *AgentsHandler) SetPreviewToolPolicy(pe *tools.PolicyEngine) {
|
|
h.toolPE = pe
|
|
}
|
|
|
|
// SetPreviewMCPManager attaches the MCP manager for store-based MCP tool preview.
|
|
// When set, configured MCP tools appear in the prompt preview even when not
|
|
// currently loaded in the live tool registry.
|
|
func (h *AgentsHandler) SetPreviewMCPManager(lister agent.MCPPreviewLister) {
|
|
h.mcpPreviewMgr = lister
|
|
}
|
|
|
|
// SetDisabledToolsStore attaches the tenant config store for per-tenant disabled
|
|
// tool filtering in system prompt preview. nil is safe — no per-tenant filtering
|
|
// is applied.
|
|
func (h *AgentsHandler) SetDisabledToolsStore(dts store.BuiltinToolTenantConfigStore) {
|
|
h.disabledToolsStore = dts
|
|
}
|
|
|
|
// 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})
|
|
}
|
|
|
|
// shouldSummonOnCreate reports whether creating an agent kicks off background
|
|
// LLM summoning, which generates its context files from the description.
|
|
//
|
|
// requested is the client's "summon" field (absent means true). Clients that
|
|
// manage agents as code pass false: they bring their own context files and
|
|
// write them immediately after create, while summoning lands ~10-20s later and
|
|
// overwrites them.
|
|
func shouldSummonOnCreate(requested bool, agentType, description string, haveSummoner bool) bool {
|
|
return requested && agentType == store.AgentTypePredefined && description != "" && haveSummoner
|
|
}
|
|
|
|
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"`
|
|
// Summon opts out of the background LLM summoning that normally runs
|
|
// for a predefined agent with a description. Default (absent) is true,
|
|
// so existing clients are unaffected.
|
|
//
|
|
// Clients that manage agents as code bring their own context files and
|
|
// write them right after create. Summoning finishes ~10-20s later and
|
|
// overwrites whatever is there, silently, so those clients either lose
|
|
// their files or have to poll the agent status before writing.
|
|
Summon *bool `json:"summon,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
|
|
summonWanted := createReq.Summon == nil || *createReq.Summon
|
|
switch {
|
|
case shouldSummonOnCreate(summonWanted, req.AgentType, description, h.summoner != nil):
|
|
req.Status = store.AgentStatusSummoning
|
|
case req.Status == "" || req.Status == store.AgentStatusSummoning:
|
|
// Never leave an agent in "summoning" when nothing will summon it:
|
|
// that status is only cleared by the summoner, so without one the
|
|
// agent would stay stuck there forever.
|
|
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})
|
|
}
|