ScuttleBot

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

Keyboard Shortcuts

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