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}) }