ScuttleBot

scuttlebot / internal / api / agents.go
Source Blame History 330 lines
2d8a379… lmata 1 package api
2d8a379… lmata 2
2d8a379… lmata 3 import (
2d8a379… lmata 4 "encoding/json"
2d8a379… lmata 5 "net/http"
2d8a379… lmata 6 "strings"
2d8a379… lmata 7
2d8a379… lmata 8 "github.com/conflicthq/scuttlebot/internal/registry"
2d8a379… lmata 9 )
2d8a379… lmata 10
2d8a379… lmata 11 type registerRequest struct {
7830697… lmata 12 Nick string `json:"nick"`
7830697… lmata 13 Type registry.AgentType `json:"type"`
7830697… lmata 14 Channels []string `json:"channels"`
3e3b163… lmata 15 OpsChannels []string `json:"ops_channels,omitempty"`
7830697… lmata 16 Permissions []string `json:"permissions"`
ba75f34… noreply 17 Skills []string `json:"skills,omitempty"`
7830697… lmata 18 RateLimit *registry.RateLimitConfig `json:"rate_limit,omitempty"`
7830697… lmata 19 Rules *registry.EngagementRules `json:"engagement,omitempty"`
2d8a379… lmata 20 }
2d8a379… lmata 21
2d8a379… lmata 22 type registerResponse struct {
1066004… lmata 23 Credentials *registry.Credentials `json:"credentials"`
2d8a379… lmata 24 Payload *registry.SignedPayload `json:"payload"`
2d8a379… lmata 25 }
2d8a379… lmata 26
2d8a379… lmata 27 func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
2d8a379… lmata 28 var req registerRequest
2d8a379… lmata 29 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
2d8a379… lmata 30 writeError(w, http.StatusBadRequest, "invalid request body")
2d8a379… lmata 31 return
2d8a379… lmata 32 }
2d8a379… lmata 33 if req.Nick == "" {
2d8a379… lmata 34 writeError(w, http.StatusBadRequest, "nick is required")
2d8a379… lmata 35 return
2d8a379… lmata 36 }
2d8a379… lmata 37 if req.Type == "" {
2d8a379… lmata 38 req.Type = registry.AgentTypeWorker
2d8a379… lmata 39 }
2d8a379… lmata 40
7830697… lmata 41 cfg := registry.EngagementConfig{
7830697… lmata 42 Channels: req.Channels,
3e3b163… lmata 43 OpsChannels: req.OpsChannels,
7830697… lmata 44 Permissions: req.Permissions,
7830697… lmata 45 }
7830697… lmata 46 if req.RateLimit != nil {
7830697… lmata 47 cfg.RateLimit = *req.RateLimit
7830697… lmata 48 }
7830697… lmata 49 if req.Rules != nil {
7830697… lmata 50 cfg.Rules = *req.Rules
7830697… lmata 51 }
7830697… lmata 52 creds, payload, err := s.registry.Register(req.Nick, req.Type, cfg)
2d8a379… lmata 53 if err != nil {
2d8a379… lmata 54 if strings.Contains(err.Error(), "already registered") {
2d8a379… lmata 55 writeError(w, http.StatusConflict, err.Error())
2d8a379… lmata 56 return
2d8a379… lmata 57 }
2d8a379… lmata 58 s.log.Error("register agent", "nick", req.Nick, "err", err)
2d8a379… lmata 59 writeError(w, http.StatusInternalServerError, "registration failed")
2d8a379… lmata 60 return
2d8a379… lmata 61 }
2d8a379… lmata 62
ba75f34… noreply 63 // Set skills if provided.
ba75f34… noreply 64 if len(req.Skills) > 0 {
ba75f34… noreply 65 if agent, err := s.registry.Get(req.Nick); err == nil {
ba75f34… noreply 66 agent.Skills = req.Skills
ba75f34… noreply 67 _ = s.registry.Update(agent)
ba75f34… noreply 68 }
ba75f34… noreply 69 }
1cbc747… lmata 70 s.registry.Touch(req.Nick)
e0d99ff… lmata 71 go s.setAgentModes(req.Nick, req.Type, cfg) // async — don't block response
2d8a379… lmata 72 writeJSON(w, http.StatusCreated, registerResponse{
2d8a379… lmata 73 Credentials: creds,
2d8a379… lmata 74 Payload: payload,
2d8a379… lmata 75 })
5ac549c… lmata 76 }
5ac549c… lmata 77
5ac549c… lmata 78 func (s *Server) handleAdopt(w http.ResponseWriter, r *http.Request) {
5ac549c… lmata 79 nick := r.PathValue("nick")
5ac549c… lmata 80 var req struct {
5ac549c… lmata 81 Type registry.AgentType `json:"type"`
5ac549c… lmata 82 Channels []string `json:"channels"`
3e3b163… lmata 83 OpsChannels []string `json:"ops_channels,omitempty"`
5ac549c… lmata 84 Permissions []string `json:"permissions"`
5ac549c… lmata 85 }
5ac549c… lmata 86 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
5ac549c… lmata 87 writeError(w, http.StatusBadRequest, "invalid request body")
5ac549c… lmata 88 return
5ac549c… lmata 89 }
5ac549c… lmata 90 if req.Type == "" {
5ac549c… lmata 91 req.Type = registry.AgentTypeWorker
5ac549c… lmata 92 }
5ac549c… lmata 93 cfg := registry.EngagementConfig{
5ac549c… lmata 94 Channels: req.Channels,
3e3b163… lmata 95 OpsChannels: req.OpsChannels,
5ac549c… lmata 96 Permissions: req.Permissions,
5ac549c… lmata 97 }
5ac549c… lmata 98 payload, err := s.registry.Adopt(nick, req.Type, cfg)
5ac549c… lmata 99 if err != nil {
5ac549c… lmata 100 if strings.Contains(err.Error(), "already registered") {
5ac549c… lmata 101 writeError(w, http.StatusConflict, err.Error())
5ac549c… lmata 102 return
5ac549c… lmata 103 }
5ac549c… lmata 104 s.log.Error("adopt agent", "nick", nick, "err", err)
5ac549c… lmata 105 writeError(w, http.StatusInternalServerError, "adopt failed")
5ac549c… lmata 106 return
5ac549c… lmata 107 }
3e3b163… lmata 108 s.setAgentModes(nick, req.Type, cfg)
5ac549c… lmata 109 writeJSON(w, http.StatusOK, map[string]any{"nick": nick, "payload": payload})
2d8a379… lmata 110 }
2d8a379… lmata 111
2d8a379… lmata 112 func (s *Server) handleRotate(w http.ResponseWriter, r *http.Request) {
2d8a379… lmata 113 nick := r.PathValue("nick")
2d8a379… lmata 114 creds, err := s.registry.Rotate(nick)
2d8a379… lmata 115 if err != nil {
2d8a379… lmata 116 if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "revoked") {
2d8a379… lmata 117 writeError(w, http.StatusNotFound, err.Error())
2d8a379… lmata 118 return
2d8a379… lmata 119 }
2d8a379… lmata 120 s.log.Error("rotate credentials", "nick", nick, "err", err)
2d8a379… lmata 121 writeError(w, http.StatusInternalServerError, "rotation failed")
2d8a379… lmata 122 return
2d8a379… lmata 123 }
2d8a379… lmata 124 writeJSON(w, http.StatusOK, creds)
2d8a379… lmata 125 }
2d8a379… lmata 126
2d8a379… lmata 127 func (s *Server) handleRevoke(w http.ResponseWriter, r *http.Request) {
2d8a379… lmata 128 nick := r.PathValue("nick")
0902a34… lmata 129 // Look up agent channels before revoking so we can remove access.
0902a34… lmata 130 if agent, err := s.registry.Get(nick); err == nil {
0902a34… lmata 131 s.removeAgentModes(nick, agent.Channels)
0902a34… lmata 132 }
2d8a379… lmata 133 if err := s.registry.Revoke(nick); err != nil {
2d8a379… lmata 134 if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "revoked") {
2d8a379… lmata 135 writeError(w, http.StatusNotFound, err.Error())
2d8a379… lmata 136 return
2d8a379… lmata 137 }
2d8a379… lmata 138 s.log.Error("revoke agent", "nick", nick, "err", err)
2d8a379… lmata 139 writeError(w, http.StatusInternalServerError, "revocation failed")
2d8a379… lmata 140 return
2d8a379… lmata 141 }
2d8a379… lmata 142 w.WriteHeader(http.StatusNoContent)
2d8a379… lmata 143 }
2d8a379… lmata 144
5ac549c… lmata 145 func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
5ac549c… lmata 146 nick := r.PathValue("nick")
0902a34… lmata 147 // Look up agent channels before deleting so we can remove access.
0902a34… lmata 148 if agent, err := s.registry.Get(nick); err == nil {
0902a34… lmata 149 s.removeAgentModes(nick, agent.Channels)
0902a34… lmata 150 }
5ac549c… lmata 151 if err := s.registry.Delete(nick); err != nil {
5ac549c… lmata 152 if strings.Contains(err.Error(), "not found") {
5ac549c… lmata 153 writeError(w, http.StatusNotFound, err.Error())
5ac549c… lmata 154 return
5ac549c… lmata 155 }
5ac549c… lmata 156 s.log.Error("delete agent", "nick", nick, "err", err)
5ac549c… lmata 157 writeError(w, http.StatusInternalServerError, "deletion failed")
5ac549c… lmata 158 return
5ac549c… lmata 159 }
5ac549c… lmata 160 w.WriteHeader(http.StatusNoContent)
0902a34… lmata 161 }
0902a34… lmata 162
50ba2ec… noreply 163 // handleBulkDeleteAgents handles POST /v1/agents/bulk-delete.
50ba2ec… noreply 164 func (s *Server) handleBulkDeleteAgents(w http.ResponseWriter, r *http.Request) {
50ba2ec… noreply 165 var req struct {
50ba2ec… noreply 166 Nicks []string `json:"nicks"`
50ba2ec… noreply 167 }
50ba2ec… noreply 168 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
50ba2ec… noreply 169 writeError(w, http.StatusBadRequest, "invalid request body")
50ba2ec… noreply 170 return
50ba2ec… noreply 171 }
50ba2ec… noreply 172 if len(req.Nicks) == 0 {
50ba2ec… noreply 173 writeError(w, http.StatusBadRequest, "nicks list is required")
50ba2ec… noreply 174 return
50ba2ec… noreply 175 }
50ba2ec… noreply 176
50ba2ec… noreply 177 var deleted, failed int
50ba2ec… noreply 178 for _, nick := range req.Nicks {
50ba2ec… noreply 179 if agent, err := s.registry.Get(nick); err == nil {
50ba2ec… noreply 180 s.removeAgentModes(nick, agent.Channels)
50ba2ec… noreply 181 }
50ba2ec… noreply 182 if err := s.registry.Delete(nick); err != nil {
50ba2ec… noreply 183 s.log.Warn("bulk delete: failed", "nick", nick, "err", err)
50ba2ec… noreply 184 failed++
50ba2ec… noreply 185 } else {
50ba2ec… noreply 186 deleted++
50ba2ec… noreply 187 }
50ba2ec… noreply 188 }
50ba2ec… noreply 189 writeJSON(w, http.StatusOK, map[string]int{"deleted": deleted, "failed": failed})
50ba2ec… noreply 190 }
50ba2ec… noreply 191
87e6978… lmata 192 func (s *Server) handleUpdateAgent(w http.ResponseWriter, r *http.Request) {
87e6978… lmata 193 nick := r.PathValue("nick")
87e6978… lmata 194 var req struct {
87e6978… lmata 195 Channels []string `json:"channels"`
87e6978… lmata 196 }
87e6978… lmata 197 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
87e6978… lmata 198 writeError(w, http.StatusBadRequest, "invalid request body")
87e6978… lmata 199 return
87e6978… lmata 200 }
87e6978… lmata 201 if err := s.registry.UpdateChannels(nick, req.Channels); err != nil {
87e6978… lmata 202 if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "revoked") {
87e6978… lmata 203 writeError(w, http.StatusNotFound, err.Error())
87e6978… lmata 204 return
87e6978… lmata 205 }
87e6978… lmata 206 s.log.Error("update agent channels", "nick", nick, "err", err)
87e6978… lmata 207 writeError(w, http.StatusInternalServerError, "update failed")
87e6978… lmata 208 return
87e6978… lmata 209 }
1cbc747… lmata 210 s.registry.Touch(nick)
87e6978… lmata 211 w.WriteHeader(http.StatusNoContent)
87e6978… lmata 212 }
87e6978… lmata 213
2d8a379… lmata 214 func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) {
2d8a379… lmata 215 agents := s.registry.List()
ba75f34… noreply 216 // Filter by skill if ?skill= query param is present.
ba75f34… noreply 217 if skill := r.URL.Query().Get("skill"); skill != "" {
ba75f34… noreply 218 filtered := make([]*registry.Agent, 0)
ba75f34… noreply 219 for _, a := range agents {
ba75f34… noreply 220 for _, s := range a.Skills {
ba75f34… noreply 221 if strings.EqualFold(s, skill) {
ba75f34… noreply 222 filtered = append(filtered, a)
ba75f34… noreply 223 break
ba75f34… noreply 224 }
ba75f34… noreply 225 }
ba75f34… noreply 226 }
ba75f34… noreply 227 agents = filtered
ba75f34… noreply 228 }
2d8a379… lmata 229 writeJSON(w, http.StatusOK, map[string]any{"agents": agents})
2d8a379… lmata 230 }
2d8a379… lmata 231
2d8a379… lmata 232 func (s *Server) handleGetAgent(w http.ResponseWriter, r *http.Request) {
2d8a379… lmata 233 nick := r.PathValue("nick")
2d8a379… lmata 234 agent, err := s.registry.Get(nick)
2d8a379… lmata 235 if err != nil {
2d8a379… lmata 236 writeError(w, http.StatusNotFound, err.Error())
2d8a379… lmata 237 return
2d8a379… lmata 238 }
2d8a379… lmata 239 writeJSON(w, http.StatusOK, agent)
ba75f34… noreply 240 }
ba75f34… noreply 241
ba75f34… noreply 242 // handleAgentBlocker handles POST /v1/agents/{nick}/blocker.
ba75f34… noreply 243 // Agents or relays call this to escalate that an agent is stuck.
ba75f34… noreply 244 func (s *Server) handleAgentBlocker(w http.ResponseWriter, r *http.Request) {
ba75f34… noreply 245 nick := r.PathValue("nick")
ba75f34… noreply 246 var req struct {
ba75f34… noreply 247 Channel string `json:"channel,omitempty"`
ba75f34… noreply 248 Message string `json:"message"`
ba75f34… noreply 249 }
ba75f34… noreply 250 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ba75f34… noreply 251 writeError(w, http.StatusBadRequest, "invalid request body")
ba75f34… noreply 252 return
ba75f34… noreply 253 }
ba75f34… noreply 254 if req.Message == "" {
ba75f34… noreply 255 writeError(w, http.StatusBadRequest, "message is required")
ba75f34… noreply 256 return
ba75f34… noreply 257 }
ba75f34… noreply 258
ba75f34… noreply 259 alert := "[blocker] " + nick
ba75f34… noreply 260 if req.Channel != "" {
ba75f34… noreply 261 alert += " in " + req.Channel
ba75f34… noreply 262 }
ba75f34… noreply 263 alert += ": " + req.Message
ba75f34… noreply 264
ba75f34… noreply 265 // Post to #ops if bridge is available.
ba75f34… noreply 266 if s.bridge != nil {
ba75f34… noreply 267 _ = s.bridge.Send(r.Context(), "#ops", alert, "")
ba75f34… noreply 268 }
ba75f34… noreply 269 s.log.Warn("agent blocker", "nick", nick, "channel", req.Channel, "message", req.Message)
ba75f34… noreply 270 w.WriteHeader(http.StatusNoContent)
0902a34… lmata 271 }
0902a34… lmata 272
0902a34… lmata 273 // agentModeLevel maps an agent type to the ChanServ access level it should
0902a34… lmata 274 // receive. Returns "" for types that get no special mode.
0902a34… lmata 275 func agentModeLevel(t registry.AgentType) string {
0902a34… lmata 276 switch t {
0902a34… lmata 277 case registry.AgentTypeOperator, registry.AgentTypeOrchestrator:
0902a34… lmata 278 return "OP"
0902a34… lmata 279 case registry.AgentTypeWorker:
0902a34… lmata 280 return "VOICE"
0902a34… lmata 281 default:
0902a34… lmata 282 return ""
0902a34… lmata 283 }
0902a34… lmata 284 }
0902a34… lmata 285
0902a34… lmata 286 // setAgentModes grants the appropriate ChanServ access for an agent on all
3e3b163… lmata 287 // its assigned channels based on its type. For orchestrators with OpsChannels
3e3b163… lmata 288 // configured, +o is granted only on those channels and +v on the rest.
3e3b163… lmata 289 // No-op when topology is not configured or the agent type doesn't warrant a mode.
3e3b163… lmata 290 func (s *Server) setAgentModes(nick string, agentType registry.AgentType, cfg registry.EngagementConfig) {
0902a34… lmata 291 if s.topoMgr == nil {
0902a34… lmata 292 return
0902a34… lmata 293 }
0902a34… lmata 294 level := agentModeLevel(agentType)
0902a34… lmata 295 if level == "" {
0902a34… lmata 296 return
0902a34… lmata 297 }
3e3b163… lmata 298
3e3b163… lmata 299 // Orchestrators with explicit OpsChannels get +o only on those channels
3e3b163… lmata 300 // and +v on remaining channels.
3e3b163… lmata 301 if level == "OP" && len(cfg.OpsChannels) > 0 {
3e3b163… lmata 302 opsSet := make(map[string]struct{}, len(cfg.OpsChannels))
3e3b163… lmata 303 for _, ch := range cfg.OpsChannels {
3e3b163… lmata 304 opsSet[ch] = struct{}{}
3e3b163… lmata 305 }
3e3b163… lmata 306 for _, ch := range cfg.Channels {
3e3b163… lmata 307 if _, isOps := opsSet[ch]; isOps {
3e3b163… lmata 308 s.topoMgr.GrantAccess(nick, ch, "OP")
3e3b163… lmata 309 } else {
3e3b163… lmata 310 s.topoMgr.GrantAccess(nick, ch, "VOICE")
3e3b163… lmata 311 }
3e3b163… lmata 312 }
3e3b163… lmata 313 return
3e3b163… lmata 314 }
3e3b163… lmata 315
3e3b163… lmata 316 for _, ch := range cfg.Channels {
0902a34… lmata 317 s.topoMgr.GrantAccess(nick, ch, level)
0902a34… lmata 318 }
0902a34… lmata 319 }
0902a34… lmata 320
0902a34… lmata 321 // removeAgentModes revokes ChanServ access for an agent on all its assigned
0902a34… lmata 322 // channels. No-op when topology is not configured.
0902a34… lmata 323 func (s *Server) removeAgentModes(nick string, channels []string) {
0902a34… lmata 324 if s.topoMgr == nil {
0902a34… lmata 325 return
0902a34… lmata 326 }
0902a34… lmata 327 for _, ch := range channels {
0902a34… lmata 328 s.topoMgr.RevokeAccess(nick, ch)
0902a34… lmata 329 }
2d8a379… lmata 330 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button