ScuttleBot

feat: agent mode assignment — +o for orchestrators, +v for workers (#100) On agent registration/adopt, set ChanServ ACCESS entries based on agent type: operator/orchestrator get OP (+o), workers get VOICE (+v), observers get no mode. On revoke/delete, remove all channel access. Uses persistent ChanServ ACLs so modes survive agent reconnects. Closes #100

lmata 2026-04-04 19:47 trunk
Commit 0902a34f3a3f20c541671ab8c06fd3d84ec244787c416176bd1acb33e62f38bb
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -56,10 +56,11 @@
5656
writeError(w, http.StatusInternalServerError, "registration failed")
5757
return
5858
}
5959
6060
s.registry.Touch(req.Nick)
61
+ s.setAgentModes(req.Nick, req.Type, cfg.Channels)
6162
writeJSON(w, http.StatusCreated, registerResponse{
6263
Credentials: creds,
6364
Payload: payload,
6465
})
6566
}
@@ -90,10 +91,11 @@
9091
}
9192
s.log.Error("adopt agent", "nick", nick, "err", err)
9293
writeError(w, http.StatusInternalServerError, "adopt failed")
9394
return
9495
}
96
+ s.setAgentModes(nick, req.Type, cfg.Channels)
9597
writeJSON(w, http.StatusOK, map[string]any{"nick": nick, "payload": payload})
9698
}
9799
98100
func (s *Server) handleRotate(w http.ResponseWriter, r *http.Request) {
99101
nick := r.PathValue("nick")
@@ -110,10 +112,14 @@
110112
writeJSON(w, http.StatusOK, creds)
111113
}
112114
113115
func (s *Server) handleRevoke(w http.ResponseWriter, r *http.Request) {
114116
nick := r.PathValue("nick")
117
+ // Look up agent channels before revoking so we can remove access.
118
+ if agent, err := s.registry.Get(nick); err == nil {
119
+ s.removeAgentModes(nick, agent.Channels)
120
+ }
115121
if err := s.registry.Revoke(nick); err != nil {
116122
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "revoked") {
117123
writeError(w, http.StatusNotFound, err.Error())
118124
return
119125
}
@@ -124,10 +130,14 @@
124130
w.WriteHeader(http.StatusNoContent)
125131
}
126132
127133
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
128134
nick := r.PathValue("nick")
135
+ // Look up agent channels before deleting so we can remove access.
136
+ if agent, err := s.registry.Get(nick); err == nil {
137
+ s.removeAgentModes(nick, agent.Channels)
138
+ }
129139
if err := s.registry.Delete(nick); err != nil {
130140
if strings.Contains(err.Error(), "not found") {
131141
writeError(w, http.StatusNotFound, err.Error())
132142
return
133143
}
@@ -172,5 +182,45 @@
172182
writeError(w, http.StatusNotFound, err.Error())
173183
return
174184
}
175185
writeJSON(w, http.StatusOK, agent)
176186
}
187
+
188
+// agentModeLevel maps an agent type to the ChanServ access level it should
189
+// receive. Returns "" for types that get no special mode.
190
+func agentModeLevel(t registry.AgentType) string {
191
+ switch t {
192
+ case registry.AgentTypeOperator, registry.AgentTypeOrchestrator:
193
+ return "OP"
194
+ case registry.AgentTypeWorker:
195
+ return "VOICE"
196
+ default:
197
+ return ""
198
+ }
199
+}
200
+
201
+// setAgentModes grants the appropriate ChanServ access for an agent on all
202
+// its assigned channels based on its type. No-op when topology is not configured
203
+// or the agent type doesn't warrant a mode.
204
+func (s *Server) setAgentModes(nick string, agentType registry.AgentType, channels []string) {
205
+ if s.topoMgr == nil {
206
+ return
207
+ }
208
+ level := agentModeLevel(agentType)
209
+ if level == "" {
210
+ return
211
+ }
212
+ for _, ch := range channels {
213
+ s.topoMgr.GrantAccess(nick, ch, level)
214
+ }
215
+}
216
+
217
+// removeAgentModes revokes ChanServ access for an agent on all its assigned
218
+// channels. No-op when topology is not configured.
219
+func (s *Server) removeAgentModes(nick string, channels []string) {
220
+ if s.topoMgr == nil {
221
+ return
222
+ }
223
+ for _, ch := range channels {
224
+ s.topoMgr.RevokeAccess(nick, ch)
225
+ }
226
+}
177227
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -56,10 +56,11 @@
56 writeError(w, http.StatusInternalServerError, "registration failed")
57 return
58 }
59
60 s.registry.Touch(req.Nick)
 
61 writeJSON(w, http.StatusCreated, registerResponse{
62 Credentials: creds,
63 Payload: payload,
64 })
65 }
@@ -90,10 +91,11 @@
90 }
91 s.log.Error("adopt agent", "nick", nick, "err", err)
92 writeError(w, http.StatusInternalServerError, "adopt failed")
93 return
94 }
 
95 writeJSON(w, http.StatusOK, map[string]any{"nick": nick, "payload": payload})
96 }
97
98 func (s *Server) handleRotate(w http.ResponseWriter, r *http.Request) {
99 nick := r.PathValue("nick")
@@ -110,10 +112,14 @@
110 writeJSON(w, http.StatusOK, creds)
111 }
112
113 func (s *Server) handleRevoke(w http.ResponseWriter, r *http.Request) {
114 nick := r.PathValue("nick")
 
 
 
 
115 if err := s.registry.Revoke(nick); 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 }
@@ -124,10 +130,14 @@
124 w.WriteHeader(http.StatusNoContent)
125 }
126
127 func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
128 nick := r.PathValue("nick")
 
 
 
 
129 if err := s.registry.Delete(nick); err != nil {
130 if strings.Contains(err.Error(), "not found") {
131 writeError(w, http.StatusNotFound, err.Error())
132 return
133 }
@@ -172,5 +182,45 @@
172 writeError(w, http.StatusNotFound, err.Error())
173 return
174 }
175 writeJSON(w, http.StatusOK, agent)
176 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
--- internal/api/agents.go
+++ internal/api/agents.go
@@ -56,10 +56,11 @@
56 writeError(w, http.StatusInternalServerError, "registration failed")
57 return
58 }
59
60 s.registry.Touch(req.Nick)
61 s.setAgentModes(req.Nick, req.Type, cfg.Channels)
62 writeJSON(w, http.StatusCreated, registerResponse{
63 Credentials: creds,
64 Payload: payload,
65 })
66 }
@@ -90,10 +91,11 @@
91 }
92 s.log.Error("adopt agent", "nick", nick, "err", err)
93 writeError(w, http.StatusInternalServerError, "adopt failed")
94 return
95 }
96 s.setAgentModes(nick, req.Type, cfg.Channels)
97 writeJSON(w, http.StatusOK, map[string]any{"nick": nick, "payload": payload})
98 }
99
100 func (s *Server) handleRotate(w http.ResponseWriter, r *http.Request) {
101 nick := r.PathValue("nick")
@@ -110,10 +112,14 @@
112 writeJSON(w, http.StatusOK, creds)
113 }
114
115 func (s *Server) handleRevoke(w http.ResponseWriter, r *http.Request) {
116 nick := r.PathValue("nick")
117 // Look up agent channels before revoking so we can remove access.
118 if agent, err := s.registry.Get(nick); err == nil {
119 s.removeAgentModes(nick, agent.Channels)
120 }
121 if err := s.registry.Revoke(nick); err != nil {
122 if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "revoked") {
123 writeError(w, http.StatusNotFound, err.Error())
124 return
125 }
@@ -124,10 +130,14 @@
130 w.WriteHeader(http.StatusNoContent)
131 }
132
133 func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
134 nick := r.PathValue("nick")
135 // Look up agent channels before deleting so we can remove access.
136 if agent, err := s.registry.Get(nick); err == nil {
137 s.removeAgentModes(nick, agent.Channels)
138 }
139 if err := s.registry.Delete(nick); err != nil {
140 if strings.Contains(err.Error(), "not found") {
141 writeError(w, http.StatusNotFound, err.Error())
142 return
143 }
@@ -172,5 +182,45 @@
182 writeError(w, http.StatusNotFound, err.Error())
183 return
184 }
185 writeJSON(w, http.StatusOK, agent)
186 }
187
188 // agentModeLevel maps an agent type to the ChanServ access level it should
189 // receive. Returns "" for types that get no special mode.
190 func agentModeLevel(t registry.AgentType) string {
191 switch t {
192 case registry.AgentTypeOperator, registry.AgentTypeOrchestrator:
193 return "OP"
194 case registry.AgentTypeWorker:
195 return "VOICE"
196 default:
197 return ""
198 }
199 }
200
201 // setAgentModes grants the appropriate ChanServ access for an agent on all
202 // its assigned channels based on its type. No-op when topology is not configured
203 // or the agent type doesn't warrant a mode.
204 func (s *Server) setAgentModes(nick string, agentType registry.AgentType, channels []string) {
205 if s.topoMgr == nil {
206 return
207 }
208 level := agentModeLevel(agentType)
209 if level == "" {
210 return
211 }
212 for _, ch := range channels {
213 s.topoMgr.GrantAccess(nick, ch, level)
214 }
215 }
216
217 // removeAgentModes revokes ChanServ access for an agent on all its assigned
218 // channels. No-op when topology is not configured.
219 func (s *Server) removeAgentModes(nick string, channels []string) {
220 if s.topoMgr == nil {
221 return
222 }
223 for _, ch := range channels {
224 s.topoMgr.RevokeAccess(nick, ch)
225 }
226 }
227
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -11,10 +11,12 @@
1111
// and query the channel policy. Satisfied by *topology.Manager.
1212
type topologyManager interface {
1313
ProvisionChannel(ch topology.ChannelConfig) error
1414
DropChannel(channel string)
1515
Policy() *topology.Policy
16
+ GrantAccess(nick, channel, level string)
17
+ RevokeAccess(nick, channel string)
1618
}
1719
1820
type provisionChannelRequest struct {
1921
Name string `json:"name"`
2022
Topic string `json:"topic,omitempty"`
2123
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -11,10 +11,12 @@
11 // and query the channel policy. Satisfied by *topology.Manager.
12 type topologyManager interface {
13 ProvisionChannel(ch topology.ChannelConfig) error
14 DropChannel(channel string)
15 Policy() *topology.Policy
 
 
16 }
17
18 type provisionChannelRequest struct {
19 Name string `json:"name"`
20 Topic string `json:"topic,omitempty"`
21
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -11,10 +11,12 @@
11 // and query the channel policy. Satisfied by *topology.Manager.
12 type topologyManager interface {
13 ProvisionChannel(ch topology.ChannelConfig) error
14 DropChannel(channel string)
15 Policy() *topology.Policy
16 GrantAccess(nick, channel, level string)
17 RevokeAccess(nick, channel string)
18 }
19
20 type provisionChannelRequest struct {
21 Name string `json:"name"`
22 Topic string `json:"topic,omitempty"`
23
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -1,10 +1,11 @@
11
package api
22
33
import (
44
"bytes"
55
"encoding/json"
6
+ "fmt"
67
"io"
78
"log/slog"
89
"net/http"
910
"net/http/httptest"
1011
"testing"
@@ -12,17 +13,26 @@
1213
1314
"github.com/conflicthq/scuttlebot/internal/config"
1415
"github.com/conflicthq/scuttlebot/internal/registry"
1516
"github.com/conflicthq/scuttlebot/internal/topology"
1617
)
18
+
19
+// accessCall records a single GrantAccess or RevokeAccess invocation.
20
+type accessCall struct {
21
+ Nick string
22
+ Channel string
23
+ Level string // "OP", "VOICE", or "" for revoke
24
+}
1725
1826
// stubTopologyManager implements topologyManager for tests.
1927
// It records the last ProvisionChannel call and returns a canned Policy.
2028
type stubTopologyManager struct {
2129
last topology.ChannelConfig
2230
policy *topology.Policy
2331
provErr error
32
+ grants []accessCall
33
+ revokes []accessCall
2434
}
2535
2636
func (s *stubTopologyManager) ProvisionChannel(ch topology.ChannelConfig) error {
2737
s.last = ch
2838
return s.provErr
@@ -29,14 +39,55 @@
2939
}
3040
3141
func (s *stubTopologyManager) DropChannel(_ string) {}
3242
3343
func (s *stubTopologyManager) Policy() *topology.Policy { return s.policy }
44
+
45
+func (s *stubTopologyManager) GrantAccess(nick, channel, level string) {
46
+ s.grants = append(s.grants, accessCall{Nick: nick, Channel: channel, Level: level})
47
+}
48
+
49
+func (s *stubTopologyManager) RevokeAccess(nick, channel string) {
50
+ s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel})
51
+}
52
+
53
+// stubProvisioner is a minimal AccountProvisioner for agent registration tests.
54
+type stubProvisioner struct {
55
+ accounts map[string]string
56
+}
57
+
58
+func newStubProvisioner() *stubProvisioner {
59
+ return &stubProvisioner{accounts: make(map[string]string)}
60
+}
61
+
62
+func (p *stubProvisioner) RegisterAccount(name, pass string) error {
63
+ if _, ok := p.accounts[name]; ok {
64
+ return fmt.Errorf("ACCOUNT_EXISTS")
65
+ }
66
+ p.accounts[name] = pass
67
+ return nil
68
+}
69
+
70
+func (p *stubProvisioner) ChangePassword(name, pass string) error {
71
+ p.accounts[name] = pass
72
+ return nil
73
+}
3474
3575
func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
3676
t.Helper()
3777
reg := registry.New(nil, []byte("key"))
78
+ log := slog.New(slog.NewTextHandler(io.Discard, nil))
79
+ srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler())
80
+ t.Cleanup(srv.Close)
81
+ return srv, "tok"
82
+}
83
+
84
+// newTopoTestServerWithRegistry creates a test server with both topology and a
85
+// real registry backed by stubProvisioner, so agent registration works.
86
+func newTopoTestServerWithRegistry(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
87
+ t.Helper()
88
+ reg := registry.New(newStubProvisioner(), []byte("key"))
3889
log := slog.New(slog.NewTextHandler(io.Discard, nil))
3990
srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler())
4091
t.Cleanup(srv.Close)
4192
return srv, "tok"
4293
}
@@ -145,5 +196,168 @@
145196
}
146197
if len(got.Types) != 1 || got.Types[0].Name != "task" {
147198
t.Errorf("types = %v", got.Types)
148199
}
149200
}
201
+
202
+// --- Agent mode assignment tests ---
203
+
204
+// topoDoJSON is a helper for issuing authenticated JSON requests against a test server.
205
+func topoDoJSON(t *testing.T, srv *httptest.Server, tok, method, path string, body any) *http.Response {
206
+ t.Helper()
207
+ var buf bytes.Buffer
208
+ if body != nil {
209
+ if err := json.NewEncoder(&buf).Encode(body); err != nil {
210
+ t.Fatalf("encode: %v", err)
211
+ }
212
+ }
213
+ req, _ := http.NewRequest(method, srv.URL+path, &buf)
214
+ req.Header.Set("Authorization", "Bearer "+tok)
215
+ req.Header.Set("Content-Type", "application/json")
216
+ resp, err := http.DefaultClient.Do(req)
217
+ if err != nil {
218
+ t.Fatalf("do: %v", err)
219
+ }
220
+ return resp
221
+}
222
+
223
+func TestRegisterGrantsOPForOrchestrator(t *testing.T) {
224
+ stub := &stubTopologyManager{}
225
+ srv, tok := newTopoTestServerWithRegistry(t, stub)
226
+
227
+ resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
228
+ "nick": "orch-1",
229
+ "type": "orchestrator",
230
+ "channels": []string{"#fleet", "#project.foo"},
231
+ })
232
+ defer resp.Body.Close()
233
+ if resp.StatusCode != http.StatusCreated {
234
+ t.Fatalf("register: want 201, got %d", resp.StatusCode)
235
+ }
236
+
237
+ if len(stub.grants) != 2 {
238
+ t.Fatalf("grants: want 2, got %d", len(stub.grants))
239
+ }
240
+ for i, want := range []accessCall{
241
+ {Nick: "orch-1", Channel: "#fleet", Level: "OP"},
242
+ {Nick: "orch-1", Channel: "#project.foo", Level: "OP"},
243
+ } {
244
+ if stub.grants[i] != want {
245
+ t.Errorf("grant[%d] = %+v, want %+v", i, stub.grants[i], want)
246
+ }
247
+ }
248
+}
249
+
250
+func TestRegisterGrantsVOICEForWorker(t *testing.T) {
251
+ stub := &stubTopologyManager{}
252
+ srv, tok := newTopoTestServerWithRegistry(t, stub)
253
+
254
+ resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
255
+ "nick": "worker-1",
256
+ "type": "worker",
257
+ "channels": []string{"#fleet"},
258
+ })
259
+ defer resp.Body.Close()
260
+ if resp.StatusCode != http.StatusCreated {
261
+ t.Fatalf("register: want 201, got %d", resp.StatusCode)
262
+ }
263
+
264
+ if len(stub.grants) != 1 {
265
+ t.Fatalf("grants: want 1, got %d", len(stub.grants))
266
+ }
267
+ if stub.grants[0].Level != "VOICE" {
268
+ t.Errorf("level = %q, want VOICE", stub.grants[0].Level)
269
+ }
270
+}
271
+
272
+func TestRegisterNoModeForObserver(t *testing.T) {
273
+ stub := &stubTopologyManager{}
274
+ srv, tok := newTopoTestServerWithRegistry(t, stub)
275
+
276
+ resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
277
+ "nick": "obs-1",
278
+ "type": "observer",
279
+ "channels": []string{"#fleet"},
280
+ })
281
+ defer resp.Body.Close()
282
+ if resp.StatusCode != http.StatusCreated {
283
+ t.Fatalf("register: want 201, got %d", resp.StatusCode)
284
+ }
285
+
286
+ if len(stub.grants) != 0 {
287
+ t.Errorf("grants: want 0, got %d — observer should get no mode", len(stub.grants))
288
+ }
289
+}
290
+
291
+func TestRegisterGrantsOPForOperator(t *testing.T) {
292
+ stub := &stubTopologyManager{}
293
+ srv, tok := newTopoTestServerWithRegistry(t, stub)
294
+
295
+ resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
296
+ "nick": "human-op",
297
+ "type": "operator",
298
+ "channels": []string{"#fleet"},
299
+ })
300
+ defer resp.Body.Close()
301
+ if resp.StatusCode != http.StatusCreated {
302
+ t.Fatalf("register: want 201, got %d", resp.StatusCode)
303
+ }
304
+
305
+ if len(stub.grants) != 1 {
306
+ t.Fatalf("grants: want 1, got %d", len(stub.grants))
307
+ }
308
+ if stub.grants[0].Level != "OP" {
309
+ t.Errorf("level = %q, want OP", stub.grants[0].Level)
310
+ }
311
+}
312
+
313
+func TestRevokeRemovesAccess(t *testing.T) {
314
+ stub := &stubTopologyManager{}
315
+ srv, tok := newTopoTestServerWithRegistry(t, stub)
316
+
317
+ resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
318
+ "nick": "orch-rev",
319
+ "type": "orchestrator",
320
+ "channels": []string{"#fleet", "#project.x"},
321
+ })
322
+ resp.Body.Close()
323
+
324
+ resp = topoDoJSON(t, srv, tok, "POST", "/v1/agents/orch-rev/revoke", nil)
325
+ defer resp.Body.Close()
326
+ if resp.StatusCode != http.StatusNoContent {
327
+ t.Fatalf("revoke: want 204, got %d", resp.StatusCode)
328
+ }
329
+
330
+ if len(stub.revokes) != 2 {
331
+ t.Fatalf("revokes: want 2, got %d", len(stub.revokes))
332
+ }
333
+ for i, want := range []string{"#fleet", "#project.x"} {
334
+ if stub.revokes[i].Channel != want {
335
+ t.Errorf("revoke[%d].Channel = %q, want %q", i, stub.revokes[i].Channel, want)
336
+ }
337
+ }
338
+}
339
+
340
+func TestDeleteRemovesAccess(t *testing.T) {
341
+ stub := &stubTopologyManager{}
342
+ srv, tok := newTopoTestServerWithRegistry(t, stub)
343
+
344
+ resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
345
+ "nick": "del-agent",
346
+ "type": "worker",
347
+ "channels": []string{"#fleet"},
348
+ })
349
+ resp.Body.Close()
350
+
351
+ resp = topoDoJSON(t, srv, tok, "DELETE", "/v1/agents/del-agent", nil)
352
+ defer resp.Body.Close()
353
+ if resp.StatusCode != http.StatusNoContent {
354
+ t.Fatalf("delete: want 204, got %d", resp.StatusCode)
355
+ }
356
+
357
+ if len(stub.revokes) != 1 {
358
+ t.Fatalf("revokes: want 1, got %d", len(stub.revokes))
359
+ }
360
+ if stub.revokes[0].Nick != "del-agent" || stub.revokes[0].Channel != "#fleet" {
361
+ t.Errorf("revoke = %+v, want del-agent on #fleet", stub.revokes[0])
362
+ }
363
+}
150364
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -1,10 +1,11 @@
1 package api
2
3 import (
4 "bytes"
5 "encoding/json"
 
6 "io"
7 "log/slog"
8 "net/http"
9 "net/http/httptest"
10 "testing"
@@ -12,17 +13,26 @@
12
13 "github.com/conflicthq/scuttlebot/internal/config"
14 "github.com/conflicthq/scuttlebot/internal/registry"
15 "github.com/conflicthq/scuttlebot/internal/topology"
16 )
 
 
 
 
 
 
 
17
18 // stubTopologyManager implements topologyManager for tests.
19 // It records the last ProvisionChannel call and returns a canned Policy.
20 type stubTopologyManager struct {
21 last topology.ChannelConfig
22 policy *topology.Policy
23 provErr error
 
 
24 }
25
26 func (s *stubTopologyManager) ProvisionChannel(ch topology.ChannelConfig) error {
27 s.last = ch
28 return s.provErr
@@ -29,14 +39,55 @@
29 }
30
31 func (s *stubTopologyManager) DropChannel(_ string) {}
32
33 func (s *stubTopologyManager) Policy() *topology.Policy { return s.policy }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
35 func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
36 t.Helper()
37 reg := registry.New(nil, []byte("key"))
 
 
 
 
 
 
 
 
 
 
 
38 log := slog.New(slog.NewTextHandler(io.Discard, nil))
39 srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler())
40 t.Cleanup(srv.Close)
41 return srv, "tok"
42 }
@@ -145,5 +196,168 @@
145 }
146 if len(got.Types) != 1 || got.Types[0].Name != "task" {
147 t.Errorf("types = %v", got.Types)
148 }
149 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -1,10 +1,11 @@
1 package api
2
3 import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "io"
8 "log/slog"
9 "net/http"
10 "net/http/httptest"
11 "testing"
@@ -12,17 +13,26 @@
13
14 "github.com/conflicthq/scuttlebot/internal/config"
15 "github.com/conflicthq/scuttlebot/internal/registry"
16 "github.com/conflicthq/scuttlebot/internal/topology"
17 )
18
19 // accessCall records a single GrantAccess or RevokeAccess invocation.
20 type accessCall struct {
21 Nick string
22 Channel string
23 Level string // "OP", "VOICE", or "" for revoke
24 }
25
26 // stubTopologyManager implements topologyManager for tests.
27 // It records the last ProvisionChannel call and returns a canned Policy.
28 type stubTopologyManager struct {
29 last topology.ChannelConfig
30 policy *topology.Policy
31 provErr error
32 grants []accessCall
33 revokes []accessCall
34 }
35
36 func (s *stubTopologyManager) ProvisionChannel(ch topology.ChannelConfig) error {
37 s.last = ch
38 return s.provErr
@@ -29,14 +39,55 @@
39 }
40
41 func (s *stubTopologyManager) DropChannel(_ string) {}
42
43 func (s *stubTopologyManager) Policy() *topology.Policy { return s.policy }
44
45 func (s *stubTopologyManager) GrantAccess(nick, channel, level string) {
46 s.grants = append(s.grants, accessCall{Nick: nick, Channel: channel, Level: level})
47 }
48
49 func (s *stubTopologyManager) RevokeAccess(nick, channel string) {
50 s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel})
51 }
52
53 // stubProvisioner is a minimal AccountProvisioner for agent registration tests.
54 type stubProvisioner struct {
55 accounts map[string]string
56 }
57
58 func newStubProvisioner() *stubProvisioner {
59 return &stubProvisioner{accounts: make(map[string]string)}
60 }
61
62 func (p *stubProvisioner) RegisterAccount(name, pass string) error {
63 if _, ok := p.accounts[name]; ok {
64 return fmt.Errorf("ACCOUNT_EXISTS")
65 }
66 p.accounts[name] = pass
67 return nil
68 }
69
70 func (p *stubProvisioner) ChangePassword(name, pass string) error {
71 p.accounts[name] = pass
72 return nil
73 }
74
75 func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
76 t.Helper()
77 reg := registry.New(nil, []byte("key"))
78 log := slog.New(slog.NewTextHandler(io.Discard, nil))
79 srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler())
80 t.Cleanup(srv.Close)
81 return srv, "tok"
82 }
83
84 // newTopoTestServerWithRegistry creates a test server with both topology and a
85 // real registry backed by stubProvisioner, so agent registration works.
86 func newTopoTestServerWithRegistry(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
87 t.Helper()
88 reg := registry.New(newStubProvisioner(), []byte("key"))
89 log := slog.New(slog.NewTextHandler(io.Discard, nil))
90 srv := httptest.NewServer(New(reg, []string{"tok"}, nil, nil, nil, nil, topo, nil, "", log).Handler())
91 t.Cleanup(srv.Close)
92 return srv, "tok"
93 }
@@ -145,5 +196,168 @@
196 }
197 if len(got.Types) != 1 || got.Types[0].Name != "task" {
198 t.Errorf("types = %v", got.Types)
199 }
200 }
201
202 // --- Agent mode assignment tests ---
203
204 // topoDoJSON is a helper for issuing authenticated JSON requests against a test server.
205 func topoDoJSON(t *testing.T, srv *httptest.Server, tok, method, path string, body any) *http.Response {
206 t.Helper()
207 var buf bytes.Buffer
208 if body != nil {
209 if err := json.NewEncoder(&buf).Encode(body); err != nil {
210 t.Fatalf("encode: %v", err)
211 }
212 }
213 req, _ := http.NewRequest(method, srv.URL+path, &buf)
214 req.Header.Set("Authorization", "Bearer "+tok)
215 req.Header.Set("Content-Type", "application/json")
216 resp, err := http.DefaultClient.Do(req)
217 if err != nil {
218 t.Fatalf("do: %v", err)
219 }
220 return resp
221 }
222
223 func TestRegisterGrantsOPForOrchestrator(t *testing.T) {
224 stub := &stubTopologyManager{}
225 srv, tok := newTopoTestServerWithRegistry(t, stub)
226
227 resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
228 "nick": "orch-1",
229 "type": "orchestrator",
230 "channels": []string{"#fleet", "#project.foo"},
231 })
232 defer resp.Body.Close()
233 if resp.StatusCode != http.StatusCreated {
234 t.Fatalf("register: want 201, got %d", resp.StatusCode)
235 }
236
237 if len(stub.grants) != 2 {
238 t.Fatalf("grants: want 2, got %d", len(stub.grants))
239 }
240 for i, want := range []accessCall{
241 {Nick: "orch-1", Channel: "#fleet", Level: "OP"},
242 {Nick: "orch-1", Channel: "#project.foo", Level: "OP"},
243 } {
244 if stub.grants[i] != want {
245 t.Errorf("grant[%d] = %+v, want %+v", i, stub.grants[i], want)
246 }
247 }
248 }
249
250 func TestRegisterGrantsVOICEForWorker(t *testing.T) {
251 stub := &stubTopologyManager{}
252 srv, tok := newTopoTestServerWithRegistry(t, stub)
253
254 resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
255 "nick": "worker-1",
256 "type": "worker",
257 "channels": []string{"#fleet"},
258 })
259 defer resp.Body.Close()
260 if resp.StatusCode != http.StatusCreated {
261 t.Fatalf("register: want 201, got %d", resp.StatusCode)
262 }
263
264 if len(stub.grants) != 1 {
265 t.Fatalf("grants: want 1, got %d", len(stub.grants))
266 }
267 if stub.grants[0].Level != "VOICE" {
268 t.Errorf("level = %q, want VOICE", stub.grants[0].Level)
269 }
270 }
271
272 func TestRegisterNoModeForObserver(t *testing.T) {
273 stub := &stubTopologyManager{}
274 srv, tok := newTopoTestServerWithRegistry(t, stub)
275
276 resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
277 "nick": "obs-1",
278 "type": "observer",
279 "channels": []string{"#fleet"},
280 })
281 defer resp.Body.Close()
282 if resp.StatusCode != http.StatusCreated {
283 t.Fatalf("register: want 201, got %d", resp.StatusCode)
284 }
285
286 if len(stub.grants) != 0 {
287 t.Errorf("grants: want 0, got %d — observer should get no mode", len(stub.grants))
288 }
289 }
290
291 func TestRegisterGrantsOPForOperator(t *testing.T) {
292 stub := &stubTopologyManager{}
293 srv, tok := newTopoTestServerWithRegistry(t, stub)
294
295 resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
296 "nick": "human-op",
297 "type": "operator",
298 "channels": []string{"#fleet"},
299 })
300 defer resp.Body.Close()
301 if resp.StatusCode != http.StatusCreated {
302 t.Fatalf("register: want 201, got %d", resp.StatusCode)
303 }
304
305 if len(stub.grants) != 1 {
306 t.Fatalf("grants: want 1, got %d", len(stub.grants))
307 }
308 if stub.grants[0].Level != "OP" {
309 t.Errorf("level = %q, want OP", stub.grants[0].Level)
310 }
311 }
312
313 func TestRevokeRemovesAccess(t *testing.T) {
314 stub := &stubTopologyManager{}
315 srv, tok := newTopoTestServerWithRegistry(t, stub)
316
317 resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
318 "nick": "orch-rev",
319 "type": "orchestrator",
320 "channels": []string{"#fleet", "#project.x"},
321 })
322 resp.Body.Close()
323
324 resp = topoDoJSON(t, srv, tok, "POST", "/v1/agents/orch-rev/revoke", nil)
325 defer resp.Body.Close()
326 if resp.StatusCode != http.StatusNoContent {
327 t.Fatalf("revoke: want 204, got %d", resp.StatusCode)
328 }
329
330 if len(stub.revokes) != 2 {
331 t.Fatalf("revokes: want 2, got %d", len(stub.revokes))
332 }
333 for i, want := range []string{"#fleet", "#project.x"} {
334 if stub.revokes[i].Channel != want {
335 t.Errorf("revoke[%d].Channel = %q, want %q", i, stub.revokes[i].Channel, want)
336 }
337 }
338 }
339
340 func TestDeleteRemovesAccess(t *testing.T) {
341 stub := &stubTopologyManager{}
342 srv, tok := newTopoTestServerWithRegistry(t, stub)
343
344 resp := topoDoJSON(t, srv, tok, "POST", "/v1/agents/register", map[string]any{
345 "nick": "del-agent",
346 "type": "worker",
347 "channels": []string{"#fleet"},
348 })
349 resp.Body.Close()
350
351 resp = topoDoJSON(t, srv, tok, "DELETE", "/v1/agents/del-agent", nil)
352 defer resp.Body.Close()
353 if resp.StatusCode != http.StatusNoContent {
354 t.Fatalf("delete: want 204, got %d", resp.StatusCode)
355 }
356
357 if len(stub.revokes) != 1 {
358 t.Fatalf("revokes: want 1, got %d", len(stub.revokes))
359 }
360 if stub.revokes[0].Nick != "del-agent" || stub.revokes[0].Channel != "#fleet" {
361 t.Errorf("revoke = %+v, want del-agent on #fleet", stub.revokes[0])
362 }
363 }
364
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -273,10 +273,29 @@
273273
for _, rec := range expired {
274274
m.log.Info("reaping expired ephemeral channel", "channel", rec.name, "age", now.Sub(rec.provisionedAt).Round(time.Minute))
275275
m.DropChannel(rec.name)
276276
}
277277
}
278
+
279
+// GrantAccess sets a ChanServ ACCESS entry for nick on the given channel.
280
+// level is "OP" or "VOICE". If level is empty, no access is granted.
281
+func (m *Manager) GrantAccess(nick, channel, level string) {
282
+ if m.client == nil || level == "" {
283
+ return
284
+ }
285
+ m.chanserv("ACCESS %s ADD %s %s", channel, nick, level)
286
+ m.log.Info("granted channel access", "nick", nick, "channel", channel, "level", level)
287
+}
288
+
289
+// RevokeAccess removes a ChanServ ACCESS entry for nick on the given channel.
290
+func (m *Manager) RevokeAccess(nick, channel string) {
291
+ if m.client == nil {
292
+ return
293
+ }
294
+ m.chanserv("ACCESS %s DEL %s", channel, nick)
295
+ m.log.Info("revoked channel access", "nick", nick, "channel", channel)
296
+}
278297
279298
func (m *Manager) chanserv(format string, args ...any) {
280299
msg := fmt.Sprintf(format, args...)
281300
m.client.Cmd.Message("ChanServ", msg)
282301
}
283302
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -273,10 +273,29 @@
273 for _, rec := range expired {
274 m.log.Info("reaping expired ephemeral channel", "channel", rec.name, "age", now.Sub(rec.provisionedAt).Round(time.Minute))
275 m.DropChannel(rec.name)
276 }
277 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
279 func (m *Manager) chanserv(format string, args ...any) {
280 msg := fmt.Sprintf(format, args...)
281 m.client.Cmd.Message("ChanServ", msg)
282 }
283
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -273,10 +273,29 @@
273 for _, rec := range expired {
274 m.log.Info("reaping expired ephemeral channel", "channel", rec.name, "age", now.Sub(rec.provisionedAt).Round(time.Minute))
275 m.DropChannel(rec.name)
276 }
277 }
278
279 // GrantAccess sets a ChanServ ACCESS entry for nick on the given channel.
280 // level is "OP" or "VOICE". If level is empty, no access is granted.
281 func (m *Manager) GrantAccess(nick, channel, level string) {
282 if m.client == nil || level == "" {
283 return
284 }
285 m.chanserv("ACCESS %s ADD %s %s", channel, nick, level)
286 m.log.Info("granted channel access", "nick", nick, "channel", channel, "level", level)
287 }
288
289 // RevokeAccess removes a ChanServ ACCESS entry for nick on the given channel.
290 func (m *Manager) RevokeAccess(nick, channel string) {
291 if m.client == nil {
292 return
293 }
294 m.chanserv("ACCESS %s DEL %s", channel, nick)
295 m.log.Info("revoked channel access", "nick", nick, "channel", channel)
296 }
297
298 func (m *Manager) chanserv(format string, args ...any) {
299 msg := fmt.Sprintf(format, args...)
300 m.client.Cmd.Message("ChanServ", msg)
301 }
302

Keyboard Shortcuts

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