|
| 1 | diff --git a/internal/daemon/agents_test.go b/internal/daemon/agents_test.go | | 2 | new file mode 100644 | | 3 | index 0000000..a2b2d62 | | 4 | --- /dev/null | | 5 | +++ b/internal/daemon/agents_test.go | | 6 | @@ -0,0 +1,257 @@ | | 7 | +package daemon | | 8 | + | | 9 | +import ( | | 10 | + "context" | | 11 | + "encoding/json" | | 12 | + "net/http" | | 13 | + "net/http/httptest" | | 14 | + "strconv" | | 15 | + "strings" | | 16 | + "testing" | | 17 | + "time" | | 18 | + | | 19 | + "github.com/FelineStateMachine/tinyrelay/internal/community" | | 20 | + "github.com/FelineStateMachine/tinyrelay/internal/event" | | 21 | + "github.com/FelineStateMachine/tinyrelay/internal/relay" | | 22 | +) | | 23 | + | | 24 | +const ( | | 25 | + testOwnerSecret = "0000000000000000000000000000000000000000000000000000000000000001" | | 26 | + testAgentSecret = "0000000000000000000000000000000000000000000000000000000000000002" | | 27 | + testMemberSecret = "0000000000000000000000000000000000000000000000000000000000000003" | | 28 | + testModSecret = "0000000000000000000000000000000000000000000000000000000000000004" | | 29 | +) | | 30 | + | | 31 | +func signedEvent(t *testing.T, secret string, kind int, createdAt int64, tags [][]string, content string) event.Event { | | 32 | + t.Helper() | | 33 | + if tags == nil { | | 34 | + tags = [][]string{} | | 35 | + } | | 36 | + e := event.Event{Kind: kind, CreatedAt: createdAt, Tags: tags, Content: content} | | 37 | + if err := event.Sign(&e, secret); err != nil { | | 38 | + t.Fatal(err) | | 39 | + } | | 40 | + return e | | 41 | +} | | 42 | + | | 43 | +func agentGrantEvent(t *testing.T, agent string, createdAt, expires int64, extra ...[]string) event.Event { | | 44 | + t.Helper() | | 45 | + tags := [][]string{{"d", agent}, {"p", agent}, {"name", "helper"}, {"expiration", strconv.FormatInt(expires, 10)}} | | 46 | + return signedEvent(t, testOwnerSecret, event.KIND_AGENT_GRANT, createdAt, append(tags, extra...), "") | | 47 | +} | | 48 | + | | 49 | +func publishAs(t *testing.T, tenant *Tenant, e event.Event) error { | | 50 | + t.Helper() | | 51 | + _, err := tenant.Publish(context.Background(), e, relay.Session{PubKeys: []string{e.PubKey}}) | | 52 | + return err | | 53 | +} | | 54 | + | | 55 | +func TestAgentGrantPublishCreatesRoleRowAndRevokesOnDeletion(t *testing.T) { | | 56 | + app, tenant := testTenant(t) | | 57 | + ctx := context.Background() | | 58 | + agent, _ := event.PublicKey(testAgentSecret) | | 59 | + now := time.Now().Unix() | | 60 | + grant := agentGrantEvent(t, agent, now, now+3600, []string{"k", "1"}) | | 61 | + if err := publishAs(t, tenant, grant); err != nil { | | 62 | + t.Fatalf("grant rejected: %v", err) | | 63 | + } | | 64 | + if role, _ := tenant.community.Role(ctx, agent); role != "agent" { | | 65 | + t.Fatalf("role = %q, want agent", role) | | 66 | + } | | 67 | + var owner, eventID, name string | | 68 | + var expires int64 | | 69 | + if err := tenant.store.DB().QueryRow(`SELECT owner,event_id,name,expires_at FROM agent_grants WHERE agent=?`, agent).Scan(&owner, &eventID, &name, &expires); err != nil { | | 70 | + t.Fatal(err) | | 71 | + } | | 72 | + if owner != tenant.Policy().Owner || eventID != grant.ID || name != "helper" || expires != now+3600 { | | 73 | + t.Fatalf("grant row owner=%s event=%s name=%s expires=%d", owner, eventID, name, expires) | | 74 | + } | | 75 | + if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now, nil, "hello")); err != nil { | | 76 | + t.Fatalf("agent note rejected: %v", err) | | 77 | + } | | 78 | + if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 7, now, nil, "+")); err == nil || !strings.HasPrefix(err.Error(), "restricted: agent grant does not allow kind 7") { | | 79 | + t.Fatalf("agent reaction: %v", err) | | 80 | + } | | 81 | + records, err := tenant.records.PublishMembership(ctx, now) | | 82 | + if err != nil { | | 83 | + t.Fatal(err) | | 84 | + } | | 85 | + labelled := map[int]bool{} | | 86 | + for _, e := range records { | | 87 | + for _, tag := range e.Tags { | | 88 | + if len(tag) == 3 && tag[1] == agent && tag[2] == "agent" && (e.Kind == event.KIND_ROSTER || e.Kind == event.KIND_GROUP_MEMBERS) { | | 89 | + labelled[e.Kind] = true | | 90 | + } | | 91 | + if e.Kind == event.KIND_ROLE_DEF && tag[0] == "d" && tag[1] == "agent" { | | 92 | + labelled[e.Kind] = true | | 93 | + } | | 94 | + } | | 95 | + } | | 96 | + if !labelled[event.KIND_ROSTER] || !labelled[event.KIND_GROUP_MEMBERS] || !labelled[event.KIND_ROLE_DEF] { | | 97 | + t.Fatalf("agent role missing from records: %v", labelled) | | 98 | + } | | 99 | + found := false | | 100 | + for _, capability := range tenant.Capabilities(nil) { | | 101 | + if capability.ID == "agents" && capability.Status == "enabled" { | | 102 | + found = true | | 103 | + } | | 104 | + } | | 105 | + if !found { | | 106 | + t.Fatal("agents capability not advertised") | | 107 | + } | | 108 | + req := httptest.NewRequest(http.MethodGet, "http://relay.test/", nil) | | 109 | + req.Header.Set("Accept", "application/nostr+json") | | 110 | + res := httptest.NewRecorder() | | 111 | + app.ServeHTTP(res, req) | | 112 | + var info map[string]json.RawMessage | | 113 | + if err := json.Unmarshal(res.Body.Bytes(), &info); err != nil || res.Code != 200 { | | 114 | + t.Fatalf("info %d %s %v", res.Code, res.Body.String(), err) | | 115 | + } | | 116 | + if !strings.Contains(string(info["agents"]), `"id":"agents"`) { | | 117 | + t.Fatalf("NIP-11 agents entry = %s", info["agents"]) | | 118 | + } | | 119 | + deletion := signedEvent(t, testOwnerSecret, event.KIND_DELETION, now+1, [][]string{{"a", "30392:" + tenant.Policy().Owner + ":" + agent}}, "") | | 120 | + if err := publishAs(t, tenant, deletion); err != nil { | | 121 | + t.Fatalf("deletion rejected: %v", err) | | 122 | + } | | 123 | + if role, _ := tenant.community.Role(ctx, agent); role != "" { | | 124 | + t.Fatalf("role after deletion = %q", role) | | 125 | + } | | 126 | + if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now+2, nil, "again")); err == nil || !strings.Contains(err.Error(), "revoked") { | | 127 | + t.Fatalf("revoked agent note: %v", err) | | 128 | + } | | 129 | + if err := publishAs(t, tenant, agentGrantEvent(t, agent, now+3, now+3600, []string{"k", "1"})); err != nil { | | 130 | + t.Fatalf("new grant rejected: %v", err) | | 131 | + } | | 132 | + if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now+4, nil, "back")); err != nil { | | 133 | + t.Fatalf("re-granted agent rejected: %v", err) | | 134 | + } | | 135 | + if err := publishAs(t, tenant, agentGrantEvent(t, agent, now+5, now-1, []string{"k", "1"})); err != nil { | | 136 | + t.Fatalf("expired grant rejected: %v", err) | | 137 | + } | | 138 | + if role, _ := tenant.community.Role(ctx, agent); role != "" { | | 139 | + t.Fatalf("role after expired grant = %q", role) | | 140 | + } | | 141 | + var revoked int64 | | 142 | + if err := tenant.store.DB().QueryRow(`SELECT revoked_at FROM agent_grants WHERE agent=?`, agent).Scan(&revoked); err != nil || revoked == 0 { | | 143 | + t.Fatalf("expired grant not revoked: %d %v", revoked, err) | | 144 | + } | | 145 | + if found := capabilityIDs(tenant)["agents"]; found { | | 146 | + t.Fatal("agents capability advertised without an active agent") | | 147 | + } | | 148 | +} | | 149 | + | | 150 | +func capabilityIDs(tenant *Tenant) map[string]bool { | | 151 | + out := map[string]bool{} | | 152 | + for _, capability := range tenant.Capabilities(nil) { | | 153 | + out[capability.ID] = true | | 154 | + } | | 155 | + return out | | 156 | +} | | 157 | + | | 158 | +func TestAgentManagementMethodsAndPermissions(t *testing.T) { | | 159 | + _, tenant := testTenant(t) | | 160 | + ctx := context.Background() | | 161 | + owner := tenant.Policy().Owner | | 162 | + agent, _ := event.PublicKey(testAgentSecret) | | 163 | + member, _ := event.PublicKey(testMemberSecret) | | 164 | + moderator, _ := event.PublicKey(testModSecret) | | 165 | + now := time.Now().Unix() | | 166 | + for _, target := range []struct { | | 167 | + pubkey, role string | | 168 | + }{{member, "member"}, {moderator, "moderator"}} { | | 169 | + raw, _ := json.Marshal(map[string]string{"role": target.role}) | | 170 | + if _, err := tenant.Execute(ctx, owner, "setmember", []json.RawMessage{json.RawMessage(strconv.Quote(target.pubkey)), raw}); err != nil { | | 171 | + t.Fatal(err) | | 172 | + } | | 173 | + } | | 174 | + if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"k", "1"})); err != nil { | | 175 | + t.Fatal(err) | | 176 | + } | | 177 | + methods, err := tenant.Execute(ctx, owner, "supportedmethods", nil) | | 178 | + if err != nil { | | 179 | + t.Fatal(err) | | 180 | + } | | 181 | + for _, name := range []string{"listagents", "pauseagent", "resumeagent", "revokeagent", "pauseallagents", "resumeallagents"} { | | 182 | + if !containsString(methods.([]string), name) { | | 183 | + t.Fatalf("supportedmethods lacks %s", name) | | 184 | + } | | 185 | + } | | 186 | + param := []json.RawMessage{json.RawMessage(strconv.Quote(agent))} | | 187 | + for _, actor := range []string{member, agent, strings.Repeat("e", 64)} { | | 188 | + for _, method := range []string{"listagents", "pauseagent", "revokeagent", "pauseallagents"} { | | 189 | + if _, err := tenant.Execute(ctx, actor, method, param); err == nil || !strings.HasPrefix(err.Error(), "restricted:") { | | 190 | + t.Fatalf("%s by non-admin: %v", method, err) | | 191 | + } | | 192 | + } | | 193 | + } | | 194 | + listed, err := tenant.Execute(ctx, moderator, "listagents", nil) | | 195 | + if err != nil { | | 196 | + t.Fatalf("moderator listagents: %v", err) | | 197 | + } | | 198 | + agents := listed.([]community.AgentSummary) | | 199 | + if len(agents) != 1 || agents[0].Agent != agent || agents[0].Name != "helper" || agents[0].Owner != owner || agents[0].Paused || agents[0].RevokedAt != 0 || agents[0].Scope.Kinds[0] != 1 { | | 200 | + t.Fatalf("listagents = %+v", agents) | | 201 | + } | | 202 | + if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now, nil, "hello")); err != nil { | | 203 | + t.Fatal(err) | | 204 | + } | | 205 | + listed, _ = tenant.Execute(ctx, owner, "listagents", nil) | | 206 | + if last := listed.([]community.AgentSummary)[0].LastEvent; last != now { | | 207 | + t.Fatalf("lastEvent = %d, want %d", last, now) | | 208 | + } | | 209 | + if _, err := tenant.Execute(ctx, moderator, "pauseagent", param); err != nil { | | 210 | + t.Fatal(err) | | 211 | + } | | 212 | + if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now+1, nil, "paused")); err == nil || !strings.Contains(err.Error(), "paused") { | | 213 | + t.Fatalf("paused agent note: %v", err) | | 214 | + } | | 215 | + if _, err := tenant.Execute(ctx, owner, "resumeagent", param); err != nil { | | 216 | + t.Fatal(err) | | 217 | + } | | 218 | + if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now+2, nil, "resumed")); err != nil { | | 219 | + t.Fatalf("resumed agent note: %v", err) | | 220 | + } | | 221 | + result, err := tenant.Execute(ctx, owner, "pauseallagents", nil) | | 222 | + if err != nil || result.(map[string]any)["changed"] != 1 { | | 223 | + t.Fatalf("pauseallagents = %v %v", result, err) | | 224 | + } | | 225 | + if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now+3, nil, "all paused")); err == nil || !strings.Contains(err.Error(), "paused") { | | 226 | + t.Fatalf("note during pause: %v", err) | | 227 | + } | | 228 | + if result, err := tenant.Execute(ctx, owner, "resumeallagents", nil); err != nil || result.(map[string]any)["changed"] != 1 { | | 229 | + t.Fatalf("resumeallagents = %v %v", result, err) | | 230 | + } | | 231 | + if _, err := tenant.Execute(ctx, owner, "revokeagent", param); err != nil { | | 232 | + t.Fatal(err) | | 233 | + } | | 234 | + if role, _ := tenant.community.Role(ctx, agent); role != "" { | | 235 | + t.Fatalf("role after revoke = %q", role) | | 236 | + } | | 237 | + if err := publishAs(t, tenant, signedEvent(t, testAgentSecret, 1, now+4, nil, "revoked")); err == nil || !strings.Contains(err.Error(), "revoked") { | | 238 | + t.Fatalf("revoked agent note: %v", err) | | 239 | + } | | 240 | + var stored int | | 241 | + if err := tenant.store.DB().QueryRow(`SELECT count(*) FROM events WHERE kind=? AND d=?`, event.KIND_AGENT_GRANT, agent).Scan(&stored); err != nil || stored != 1 { | | 242 | + t.Fatalf("grant event kept for audit: %d %v", stored, err) | | 243 | + } | | 244 | + if _, err := tenant.Execute(ctx, owner, "pauseagent", param); err == nil { | | 245 | + t.Fatal("paused a revoked agent") | | 246 | + } | | 247 | + if _, err := tenant.Execute(ctx, owner, "revokeagent", []json.RawMessage{json.RawMessage(strconv.Quote(strings.Repeat("f", 64)))}); err == nil { | | 248 | + t.Fatal("revoked an unknown agent") | | 249 | + } | | 250 | + audit, err := tenant.Execute(ctx, owner, "listaudit", nil) | | 251 | + if err != nil { | | 252 | + t.Fatal(err) | | 253 | + } | | 254 | + seen := map[string]bool{} | | 255 | + for _, row := range audit.([]community.AuditRow) { | | 256 | + seen[row.Action] = true | | 257 | + } | | 258 | + for _, action := range []string{"grantagent", "pauseagent", "resumeagent", "pauseallagents", "resumeallagents", "revokeagent"} { | | 259 | + if !seen[action] { | | 260 | + t.Fatalf("audit lacks %s: %v", action, seen) | | 261 | + } | | 262 | + } | | 263 | +} | | 264 | diff --git a/internal/daemon/capabilities.go b/internal/daemon/capabilities.go | | 265 | index 9af6d8b..f4e8dc8 100644 | | 266 | --- a/internal/daemon/capabilities.go | | 267 | +++ b/internal/daemon/capabilities.go | | 268 | @@ -44,6 +44,9 @@ func (t *Tenant) Capabilities(peers *PeerMonitor) []Capability { | | 269 | if t.community != nil { | | 270 | setCapability(capabilities, "NIP-43", Capability{ID: "NIP-43", Status: "enabled", Reason: "membership protocol"}) | | 271 | setCapability(capabilities, "NIP-56", Capability{ID: "NIP-56", Status: "enabled", Reason: "moderation reports"}) | | 272 | + if active, err := t.community.ActiveAgentCount(context.Background(), time.Now().Unix()); err == nil && active > 0 { | | 273 | + capabilities = append(capabilities, Capability{ID: "agents", Status: "enabled", Reason: "agent identities under owner-signed grants"}) | | 274 | + } | | 275 | } | | 276 | if t.blobs != nil && p.Features.Files { | | 277 | setCapability(capabilities, "NIP-94", Capability{ID: "NIP-94", Status: "enabled", Reason: "file metadata descriptors"}) | | 278 | diff --git a/internal/daemon/http.go b/internal/daemon/http.go | | 279 | index d09873a..c9a1e10 100644 | | 280 | --- a/internal/daemon/http.go | | 281 | +++ b/internal/daemon/http.go | | 282 | @@ -322,10 +322,15 @@ func statusFor(err error) int { | | 283 | func (t *Tenant) information(w http.ResponseWriter, r *http.Request) { | | 284 | p := t.Policy() | | 285 | nips := []int{} | | 286 | + var agents *Capability | | 287 | for _, capability := range t.Capabilities(nil) { | | 288 | if capability.Status != "enabled" { | | 289 | continue | | 290 | } | | 291 | + if capability.ID == "agents" { | | 292 | + entry := capability | | 293 | + agents = &entry | | 294 | + } | | 295 | if number, ok := capabilityNIPNumber(capability.ID); ok { | | 296 | nips = append(nips, number) | | 297 | } | | 298 | @@ -362,6 +367,9 @@ func (t *Tenant) information(w http.ResponseWriter, r *http.Request) { | | 299 | if p.Features.Grasp && t.git != nil { | | 300 | info["supported_grasps"] = t.git.SupportedGRASPs() | | 301 | } | | 302 | + if agents != nil { | | 303 | + info["agents"] = *agents | | 304 | + } | | 305 | if p.Features.Sites.Enabled && !privatePolicy(p) { | | 306 | base, _ := url.Parse(t.publicURL) | | 307 | domain := t.siteDomain() | | 308 | diff --git a/internal/daemon/management.go b/internal/daemon/management.go | | 309 | index 69d7e6e..bc6f79d 100644 | | 310 | --- a/internal/daemon/management.go | | 311 | +++ b/internal/daemon/management.go | | 312 | @@ -19,6 +19,7 @@ import ( | | 313 | "time" | | 314 | | | 315 | "github.com/FelineStateMachine/tinyrelay/internal/catalog" | | 316 | + "github.com/FelineStateMachine/tinyrelay/internal/community" | | 317 | "github.com/FelineStateMachine/tinyrelay/internal/domains" | | 318 | "github.com/FelineStateMachine/tinyrelay/internal/event" | | 319 | "github.com/FelineStateMachine/tinyrelay/internal/policy" | | 320 | @@ -88,6 +89,8 @@ func (t *Tenant) executeManagement(ctx context.Context, actor, method string, pa | | 321 | return t.identity(), true, nil | | 322 | case "transferowner": | | 323 | return t.transferOwner(ctx, actor, params) | | 324 | + case "listagents", "pauseagent", "resumeagent", "revokeagent", "pauseallagents", "resumeallagents": | | 325 | + return t.agentExecute(ctx, actor, method, params) | | 326 | case "forkrelay": | | 327 | return t.forkRelay(ctx, actor, params) | | 328 | case "deleterelay": | | 329 | @@ -98,7 +101,7 @@ func (t *Tenant) executeManagement(ctx context.Context, actor, method string, pa | | 330 | } | | 331 | | | 332 | func managementAdapterMethod(method string) bool { | | 333 | - for _, name := range []string{"listconnectiontemplates", "listconnections", "setconnections", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains", "listblobs", "deleteblob", "listsites", "deleteevent", "listrecentevents", "searchevents", "storagestats", "publishview", "gitstorage", "resetrules", "getidentity", "transferowner", "forkrelay", "deleterelay"} { | | 334 | + for _, name := range []string{"listconnectiontemplates", "listconnections", "setconnections", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains", "listblobs", "deleteblob", "listsites", "deleteevent", "listrecentevents", "searchevents", "storagestats", "publishview", "gitstorage", "resetrules", "getidentity", "transferowner", "forkrelay", "deleterelay", "listagents", "pauseagent", "resumeagent", "revokeagent", "pauseallagents", "resumeallagents"} { | | 335 | if method == name { | | 336 | return true | | 337 | } | | 338 | @@ -125,7 +128,7 @@ func publicManagementMethod(method string) bool { | | 339 | // ManagementMethods is the source-compatible NIP-86 registry. The hosted | | 340 | // lease, trial, fuel, and entitlement controls are intentionally absent. | | 341 | func ManagementMethods() []string { | | 342 | - return []string{"supportedmethods", "listaudit", "stats", "getpolicy", "setpolicy", "listviews", "banpubkey", "setblockedwords", "allowpubkey", "setmember", "unrulepubkey", "removemember", "listbannedpubkeys", "listallowedpubkeys", "listmembers", "listpeople", "createinvite", "listinvites", "revokeinvite", "listclaims", "listlisthistory", "restorelist", "createclaim", "deleteclaim", "removesubtree", "banevent", "allowevent", "listeventsneedingmoderation", "blockip", "unblockip", "listblockedips", "listreports", "resolvereport", "exportconfig", "importconfig", "deleterelay", "listblobs", "listsites", "deleteblob", "deleteevent", "listrecentevents", "searchevents", "pinevent", "unpinevent", "listpins", "allowkind", "disallowkind", "unrulekind", "storagestats", "gitstorage", "setretention", "listretention", "purgekind", "listallowedkinds", "listblockedkinds", "notifytest", "resetrules", "listpresets", "listconnectiontemplates", "listconnections", "setconnections", "applypreset", "forkrelay", "pullfrom", "pullstatus", "listjobs", "deliverystatus", "addjob", "removejob", "runjob", "backfill", "transferowner", "listdumps", "deletedump", "dumpnow", "backupnow", "listbackups", "deletebackup", "setsuccession", "clearsuccession", "successionstatus", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains"} | | 343 | + return []string{"supportedmethods", "listaudit", "stats", "getpolicy", "setpolicy", "listviews", "banpubkey", "setblockedwords", "allowpubkey", "setmember", "unrulepubkey", "removemember", "listbannedpubkeys", "listallowedpubkeys", "listmembers", "listpeople", "createinvite", "listinvites", "revokeinvite", "listclaims", "listlisthistory", "restorelist", "createclaim", "deleteclaim", "removesubtree", "banevent", "allowevent", "listeventsneedingmoderation", "blockip", "unblockip", "listblockedips", "listreports", "resolvereport", "exportconfig", "importconfig", "deleterelay", "listblobs", "listsites", "deleteblob", "deleteevent", "listrecentevents", "searchevents", "pinevent", "unpinevent", "listpins", "allowkind", "disallowkind", "unrulekind", "storagestats", "gitstorage", "setretention", "listretention", "purgekind", "listallowedkinds", "listblockedkinds", "notifytest", "resetrules", "listpresets", "listconnectiontemplates", "listconnections", "setconnections", "applypreset", "forkrelay", "pullfrom", "pullstatus", "listjobs", "deliverystatus", "addjob", "removejob", "runjob", "backfill", "transferowner", "listdumps", "deletedump", "dumpnow", "backupnow", "listbackups", "deletebackup", "setsuccession", "clearsuccession", "successionstatus", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains", "listagents", "pauseagent", "resumeagent", "revokeagent", "pauseallagents", "resumeallagents"} | | 344 | } | | 345 | | | 346 | func managementRoleAllows(method, role string) bool { | | 347 | @@ -135,7 +138,7 @@ func managementRoleAllows(method, role string) bool { | | 348 | if role != "moderator" { | | 349 | return false | | 350 | } | | 351 | - for _, name := range []string{"listconnectiontemplates", "listconnections", "listblobs", "listsites", "listrecentevents", "searchevents", "storagestats", "deleteevent"} { | | 352 | + for _, name := range []string{"listconnectiontemplates", "listconnections", "listblobs", "listsites", "listrecentevents", "searchevents", "storagestats", "deleteevent", "listagents", "pauseagent", "resumeagent", "revokeagent", "pauseallagents", "resumeallagents"} { | | 353 | if method == name { | | 354 | return true | | 355 | } | | 356 | @@ -276,6 +279,44 @@ func (t *Tenant) transferOwner(ctx context.Context, actor string, params []json. | | 357 | return map[string]any{"owner": next, "previousOwner": current}, true, nil | | 358 | } | | 359 | | | 360 | +// agentExecute serves the agent kill switch and inventory. The community | | 361 | +// service records each change in the audit log; this adapter only shapes | | 362 | +// parameters and logs counts. | | 363 | +func (t *Tenant) agentExecute(ctx context.Context, actor, method string, params []json.RawMessage) (any, bool, error) { | | 364 | + now := time.Now().Unix() | | 365 | + switch method { | | 366 | + case "listagents": | | 367 | + agents, err := t.community.Agents(ctx) | | 368 | + return agents, true, err | | 369 | + case "pauseallagents", "resumeallagents": | | 370 | + changed, err := t.community.SetAllAgentsPaused(ctx, actor, method == "pauseallagents") | | 371 | + if err != nil { | | 372 | + return nil, true, err | | 373 | + } | | 374 | + t.app.telemetry.Logger().Info("agent grants changed", "tenant", t.meta.Name, "action", method, "changed", changed) | | 375 | + return map[string]any{"changed": changed, "paused": method == "pauseallagents"}, true, nil | | 376 | + } | | 377 | + agent := stringParam(params, 0) | | 378 | + if len(agent) != 64 { | | 379 | + return nil, true, errors.New("invalid: agent public key required") | | 380 | + } | | 381 | + if _, err := hex.DecodeString(agent); err != nil { | | 382 | + return nil, true, errors.New("invalid: agent public key required") | | 383 | + } | | 384 | + var grant community.AgentGrant | | 385 | + var err error | | 386 | + switch method { | | 387 | + case "pauseagent", "resumeagent": | | 388 | + grant, err = t.community.SetAgentPaused(ctx, actor, agent, method == "pauseagent") | | 389 | + case "revokeagent": | | 390 | + grant, err = t.community.RevokeAgent(ctx, actor, agent, now) | | 391 | + } | | 392 | + if err != nil { | | 393 | + return nil, true, err | | 394 | + } | | 395 | + return grant, true, nil | | 396 | +} | | 397 | + | | 398 | func (t *Tenant) deleteRelay(ctx context.Context, params []json.RawMessage) (any, bool, error) { | | 399 | name := stringParam(params, 0) | | 400 | if name == "" || !strings.EqualFold(name, t.meta.Name) { | | 401 | @@ -403,7 +444,7 @@ func copyForkConfig(ctx context.Context, source, target *storage.Store) error { | | 402 | } | | 403 | | | 404 | func copyForkPeople(ctx context.Context, source, target *storage.Store, owner string) error { | | 405 | - rows, err := source.DB().QueryContext(ctx, `SELECT pubkey,name,note,role,invited_by,via,created_at,keep_days FROM community_members WHERE role<>'owner'`) | | 406 | + rows, err := source.DB().QueryContext(ctx, `SELECT pubkey,name,note,role,invited_by,via,created_at,keep_days FROM community_members WHERE role<>'owner' AND role<>'agent'`) | | 407 | if err != nil { | | 408 | return fmt.Errorf("fork members: %w", err) | | 409 | } | | 410 | diff --git a/internal/daemon/services.go b/internal/daemon/services.go | | 411 | index c47c10f..985721c 100644 | | 412 | --- a/internal/daemon/services.go | | 413 | +++ b/internal/daemon/services.go | | 414 | @@ -652,6 +652,7 @@ func (t *Tenant) commitImported(ctx context.Context, e event.Event, origin repli | | 415 | if e.Kind == 30023 { | | 416 | opts.Intents = append(opts.Intents, storage.Intent{Kind: "view-publish", EventID: e.ID, Target: "articles", Payload: "{}"}) | | 417 | } | | 418 | + opts.BeforeCommit = t.agentBeforeCommit(e, opts.Now, opts.BeforeCommit) | | 419 | _, err := t.store.Save(ctx, e, opts) | | 420 | if err == nil { | | 421 | t.notifyDevices(ctx, e) | | 422 | diff --git a/internal/daemon/tenant.go b/internal/daemon/tenant.go | | 423 | index e3c58e2..59190b2 100644 | | 424 | --- a/internal/daemon/tenant.go | | 425 | +++ b/internal/daemon/tenant.go | | 426 | @@ -275,6 +275,7 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s | | 427 | }) | | 428 | } | | 429 | } | | 430 | + opts.BeforeCommit = t.agentBeforeCommit(e, now, opts.BeforeCommit) | | 431 | opts.Intents = append(opts.Intents, t.replication.Prepare(e, replication.OriginClient)...) | | 432 | if callbackIntents, callbackErr := t.PrepareReplicationCallbacks(ctx, e); callbackErr != nil { | | 433 | return "", callbackErr | | 434 | @@ -392,6 +393,22 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s | | 435 | return "", nil | | 436 | } | | 437 | | | 438 | +// agentBeforeCommit mirrors agent grants and grant deletions into the agent | | 439 | +// table and roster in the same transaction that stores the event. | | 440 | +func (t *Tenant) agentBeforeCommit(e event.Event, now int64, before func(context.Context, *sql.Tx) error) func(context.Context, *sql.Tx) error { | | 441 | + if e.Kind != event.KIND_AGENT_GRANT && e.Kind != event.KIND_DELETION { | | 442 | + return before | | 443 | + } | | 444 | + return func(txCtx context.Context, tx *sql.Tx) error { | | 445 | + if before != nil { | | 446 | + if err := before(txCtx, tx); err != nil { | | 447 | + return err | | 448 | + } | | 449 | + } | | 450 | + return t.community.ApplyAgentEventTx(txCtx, tx, e, now) | | 451 | + } | | 452 | +} | | 453 | + | | 454 | func (t *Tenant) vanish(ctx context.Context, e event.Event) error { | | 455 | for _, tag := range e.Tags { | | 456 | if len(tag) > 1 && tag[0] == "relay" && (tag[1] == "ALL_RELAYS" || strings.TrimSuffix(tag[1], "/") == strings.TrimSuffix(t.RelayURL(), "/")) { |
|
About| owner | c01503e8035f |
|---|
| visibility | public |
|---|
| head | e69d1926bc0c |
|---|
Maintainers- c01503e8035f owner
- ba1b5beed5b5 agent hermes
Clonehttps://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git
|