tinyrelaycommit

Commit 615c53c54955

browse files at this commit | history

1diff --git a/internal/community/agents.go b/internal/community/agents.go
2index 696eef3..24173e8 100644
3--- a/internal/community/agents.go
4+++ b/internal/community/agents.go
5@@ -32,14 +32,23 @@ const (
6
7 const agentSchema = `CREATE TABLE IF NOT EXISTS agent_grants(agent TEXT PRIMARY KEY,owner TEXT NOT NULL,event_id TEXT NOT NULL,name TEXT NOT NULL DEFAULT '',expires_at INTEGER NOT NULL,paused INTEGER NOT NULL DEFAULT 0,revoked_at INTEGER NOT NULL DEFAULT 0,scope TEXT NOT NULL DEFAULT '{}');`
8
9-// AgentRepo is one repository an agent may work in. Level is "read" or
10-// "maintain"; maintain includes read.
11+// AgentRepo is one repository an agent may work in. Level is "propose",
12+// "read" or "maintain". Propose and read publish the same kinds, but each
13+// event from a propose grant is a proposal that waits for a maintainer's
14+// decision; maintain adds status changes and pushes.
15 type AgentRepo struct {
16 Owner string `json:"owner"`
17 Identifier string `json:"identifier"`
18 Level string `json:"level"`
19 }
20
21+// Repository grant levels.
22+const (
23+ AgentRepoPropose = "propose"
24+ AgentRepoRead = "read"
25+ AgentRepoMaintain = "maintain"
26+)
27+
28 // Long task grants. AgentJobsRequest lets the agent publish job requests,
29 // AgentJobsServe lets it answer requests the relay holds with results and
30 // feedback, and AgentJobsBoth does both.
31@@ -96,7 +105,15 @@ func (g AgentGrant) Active(now int64) bool {
32 // Maintains reports whether the grant currently makes the agent a maintainer
33 // of one repository: it is active and holds maintain on that exact repository.
34 func (g AgentGrant) Maintains(owner, identifier string, now int64) bool {
35- return g.Active(now) && g.RepoLevel(owner, identifier) == "maintain"
36+ return g.Active(now) && g.RepoLevel(owner, identifier) == AgentRepoMaintain
37+}
38+
39+// Proposes reports whether the grant holds propose on one repository, so
40+// the agent's issues, pull requests, patches and comments there are
41+// proposals. A paused, revoked or expired grant keeps counting, so an
42+// agent's pending events do not surface when its grant ends.
43+func (g AgentGrant) Proposes(owner, identifier string) bool {
44+ return g.RepoLevel(owner, identifier) == AgentRepoPropose
45 }
46
47 // AllowsKind reports whether the agent may publish this kind. Profiles, relay
48@@ -245,7 +262,7 @@ func (g AgentGrant) Check(e event.Event, now int64) error {
49 if level == "" {
50 return fmt.Errorf("restricted: agent grant does not allow repository %s:%s", coordinate[0], coordinate[1])
51 }
52- if gitStatusKind(e.Kind) && level != "maintain" {
53+ if gitStatusKind(e.Kind) && level != AgentRepoMaintain {
54 return fmt.Errorf("restricted: agent grant does not allow status changes in repository %s:%s", coordinate[0], coordinate[1])
55 }
56 }
57@@ -344,7 +361,7 @@ func ParseAgentGrant(e event.Event, now int64) (AgentGrant, error) {
58 for _, value := range event.TagValues(e, "repo") {
59 repo, ok := parseAgentRepo(value)
60 if !ok {
61- return AgentGrant{}, errors.New("invalid: agent grant repo tag must be owner:identifier:read or owner:identifier:maintain")
62+ return AgentGrant{}, errors.New("invalid: agent grant repo tag must be owner:identifier followed by :propose, :read or :maintain")
63 }
64 if grant.RepoLevel(repo.Owner, repo.Identifier) == "" {
65 grant.Scope.Repos = append(grant.Scope.Repos, repo)
66@@ -408,7 +425,7 @@ func parseAgentRepo(value string) (AgentRepo, bool) {
67 return AgentRepo{}, false
68 }
69 level := value[last+1:]
70- if level != "read" && level != "maintain" {
71+ if level != AgentRepoPropose && level != AgentRepoRead && level != AgentRepoMaintain {
72 return AgentRepo{}, false
73 }
74 parts := strings.SplitN(value[:last], ":", 2)
75diff --git a/internal/gates/agents_test.go b/internal/gates/agents_test.go
76index 00d5b9a..d7ba7e7 100644
77--- a/internal/gates/agents_test.go
78+++ b/internal/gates/agents_test.go
79@@ -132,15 +132,27 @@ func TestAgentGrantScopesKindsRoomsAndRepositories(t *testing.T) {
80 if err := f.gate.Write(ctx, signed(t, agentSecret, 1630, agentNow, [][]string{{"a", address}}, "status"), session, agentNow); err != nil {
81 t.Fatalf("maintainer status rejected: %v", err)
82 }
83+ // Propose publishes the same kinds as read; status changes still need
84+ // maintain.
85+ f.grant(t, agentNow+2, grantTags(f.agent, agentNow+3600, []string{"repo", repo + ":propose"}, []string{"k", "1621"}, []string{"k", "1618"}, []string{"k", "1617"}, []string{"k", "1111"}, []string{"k", "1630"}))
86+ for _, kind := range []int{1621, 1618, 1617, 1111} {
87+ if err := f.gate.Write(ctx, signed(t, agentSecret, kind, agentNow, [][]string{{"a", address}}, "proposal"), session, agentNow); err != nil {
88+ t.Fatalf("kind %d in proposable repository rejected: %v", kind, err)
89+ }
90+ }
91+ expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 1630, agentNow, [][]string{{"a", address}}, "status"), session, agentNow), "status changes")
92+ if _, err := community.ParseAgentGrant(signed(t, agentOwnerSecret, event.KIND_AGENT_GRANT, agentNow, grantTags(f.agent, agentNow+3600, []string{"repo", repo + ":admin"}), ""), agentNow); err == nil || !strings.Contains(err.Error(), ":propose, :read or :maintain") {
93+ t.Fatalf("unknown level accepted: %v", err)
94+ }
95 // Wiki access carries the wiki kinds without listing them.
96- f.grant(t, agentNow+2, grantTags(f.agent, agentNow+3600, []string{"wiki", "propose"}))
97+ f.grant(t, agentNow+3, grantTags(f.agent, agentNow+3600, []string{"wiki", "propose"}))
98 for _, kind := range []int{event.KIND_WIKI_ARTICLE, event.KIND_WIKI_MERGE} {
99 if err := f.gate.Write(ctx, signed(t, agentSecret, kind, agentNow, [][]string{{"d", "notes"}, {"title", "Notes"}, {"a", "30818:" + f.owner + ":notes"}, {"p", f.owner}, {"e", strings.Repeat("e", 64), "", "source"}}, "wiki"), session, agentNow); err != nil {
100 t.Fatalf("wiki kind %d rejected for propose: %v", kind, err)
101 }
102 }
103 expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, event.KIND_WIKI_REDIRECT, agentNow, [][]string{{"d", "notes"}}, ""), session, agentNow), "kind 30819")
104- f.grant(t, agentNow+3, grantTags(f.agent, agentNow+3600, []string{"wiki", "edit"}))
105+ f.grant(t, agentNow+4, grantTags(f.agent, agentNow+3600, []string{"wiki", "edit"}))
106 if err := f.gate.Write(ctx, signed(t, agentSecret, event.KIND_WIKI_REDIRECT, agentNow, [][]string{{"d", "notes"}, {"redirect", "30818:" + f.owner + ":notes"}}, ""), session, agentNow); err != nil {
107 t.Fatalf("redirect rejected for edit: %v", err)
108 }
109diff --git a/internal/webui/agents_test.go b/internal/webui/agents_test.go
110index 53b7ee4..fb28293 100644
111--- a/internal/webui/agents_test.go
112+++ b/internal/webui/agents_test.go
113@@ -34,7 +34,7 @@ func (b *agentsBackend) Query(_ context.Context, method string, params []json.Ra
114 scope := func(rooms []string, repos []map[string]any, wiki string, kinds []int) map[string]any {
115 return map[string]any{"rooms": rooms, "repos": repos, "wiki": wiki, "kinds": kinds, "rate": 60}
116 }
117- hermes := scope([]string{"build", "agents"}, []map[string]any{{"owner": owner, "identifier": "tinyrelay", "level": "maintain"}}, "propose", []int{9, 1111, 1621})
118+ hermes := scope([]string{"build", "agents"}, []map[string]any{{"owner": owner, "identifier": "tinyrelay", "level": "maintain"}, {"owner": owner, "identifier": "docs", "level": "propose"}}, "propose", []int{9, 1111, 1621})
119 hermes["sites"] = []map[string]any{{"label": agentSite, "ttl": 30, "encrypted": true}, {"label": "*"}}
120 agents := []any{
121 map[string]any{"pubkey": agentActive, "owner": owner, "name": "hermes", "expires": now + 86400*30, "paused": false, "revoked": 0, "lastEvent": now - 120, "scope": hermes},
122@@ -92,7 +92,7 @@ func TestAgentsPageRendersTableCardsActivityAndGrantForm(t *testing.T) {
123 `<a href="/manage/agents" aria-current="page">/agents</a>`,
124 `<table id="rows">`,
125 `<a href="#agent-` + agentActive + `">hermes</a>`,
126- `rooms: build, agents | repos: tinyrelay (maintain) | wiki: propose | kinds: 9, 1111, 1621 | sites: ` + agentSite + `, *`,
127+ `rooms: build, agents | repos: tinyrelay (maintain), docs (propose) | wiki: propose | kinds: 9, 1111, 1621 | sites: ` + agentSite + `, *`,
128 `<tr><th>sites</th><td><code>` + agentSite + `</code> 30 days encrypted<br><code>*</code></td></tr>`,
129 `<tr><th>sites</th><td>none</td></tr>`,
130 `<textarea name="sites" placeholder="npub1... ttl=30 encrypted&#10;* ttl=7"></textarea>`,
131@@ -103,6 +103,8 @@ func TestAgentsPageRendersTableCardsActivityAndGrantForm(t *testing.T) {
132 `<agent-card id="agent-` + agentActive + `">`,
133 `<h3>hermes <small><span data-state="active">active</span> | signed by you | expires `,
134 `<code>tinyrelay</code> maintain, owner <nostr-name pubkey="` + backend.policy.Owner + `"`,
135+ `<br><code>docs</code> propose, owner <nostr-name pubkey="` + backend.policy.Owner + `"`,
136+ `<textarea name="repos" placeholder="owner pubkey:identifier:propose&#10;owner pubkey:identifier:read&#10;owner pubkey:identifier:maintain">`,
137 `<th>rate</th><td>60 events per minute</td>`,
138 `<rpc-form method="pauseagent" refresh><input type="hidden" name="param" value="&quot;` + agentActive + `&quot;"><button>Pause</button></rpc-form>`,
139 `<rpc-form method="revokeagent" refresh><input type="hidden" name="param" value="&quot;` + agentActive + `&quot;"><button>Revoke</button></rpc-form>`,
140@@ -239,7 +241,8 @@ func TestAgentsPageEditPrefillsTheGrantForm(t *testing.T) {
141 `<option value="paste" selected>paste a public key</option>`,
142 `<input name="pubkey" pattern="[0-9a-f]{64}" placeholder="64 hex characters" value="` + agentActive + `">`,
143 `placeholder="build, agents" value="build, agents">`,
144- `>` + strings.Repeat("a", 64) + `:tinyrelay:maintain</textarea>`,
145+ `>` + strings.Repeat("a", 64) + `:tinyrelay:maintain
146+` + strings.Repeat("a", 64) + `:docs:propose</textarea>`,
147 `<option value="propose" selected>propose</option>`,
148 `<textarea name="sites" placeholder="npub1... ttl=30 encrypted&#10;* ttl=7">` + agentSite + ` ttl=30 encrypted
149 *</textarea>`,
150diff --git a/internal/webui/components.js b/internal/webui/components.js
151index b0ade96..5c897cb 100644
152--- a/internal/webui/components.js
153+++ b/internal/webui/components.js
154@@ -532,8 +532,8 @@
155 }
156 const seenRepos = new Set();
157 for (const line of text("repos").split(/\r?\n/).map(line => line.trim()).filter(Boolean)) {
158- const match = /^([0-9a-f]{64}):(.+):(read|maintain)$/.exec(line);
159- if (!match || match[2].length > 256) throw Error("Repositories are owner pubkey:identifier:read or maintain, one per line.");
160+ const match = /^([0-9a-f]{64}):(.+):(propose|read|maintain)$/.exec(line);
161+ if (!match || match[2].length > 256) throw Error("Repositories are owner pubkey:identifier:propose, read or maintain, one per line.");
162 if (seenRepos.has(match[1] + ":" + match[2])) continue;
163 seenRepos.add(match[1] + ":" + match[2]);
164 tags.push(["repo", line]);
165diff --git a/internal/webui/manage.html b/internal/webui/manage.html
166index c06b009..1f46e1a 100644
167--- a/internal/webui/manage.html
168+++ b/internal/webui/manage.html
169@@ -69,7 +69,7 @@
170 <label>Key <select name="key"><option value="generate">generate here, show once</option><option value="paste"{{if $edit}} selected{{end}}>paste a public key</option></select></label>
171 <label>Public key <input name="pubkey" pattern="[0-9a-f]{64}" placeholder="64 hex characters"{{with $edit}} value="{{str (index (asMap .) "pubkey")}}"{{end}}></label>
172 <label>Rooms <input name="rooms" placeholder="build, agents"{{with $edit}} value="{{scopeList . "rooms"}}"{{end}}></label>
173-<label>Repositories <textarea name="repos" placeholder="owner pubkey:identifier:read&#10;owner pubkey:identifier:maintain">{{with $edit}}{{repoLines .}}{{end}}</textarea></label>
174+<label>Repositories <textarea name="repos" placeholder="owner pubkey:identifier:propose&#10;owner pubkey:identifier:read&#10;owner pubkey:identifier:maintain">{{with $edit}}{{repoLines .}}{{end}}</textarea></label>
175 <label>Wiki <select name="wiki"><option value="">none</option><option value="propose"{{if eq (str (index $es "wiki")) "propose"}} selected{{end}}>propose</option><option value="edit"{{if eq (str (index $es "wiki")) "edit"}} selected{{end}}>edit</option></select></label>
176 <label>Sites <textarea name="sites" placeholder="npub1... ttl=30 encrypted&#10;* ttl=7">{{with $edit}}{{siteLines .}}{{end}}</textarea></label>
177 <label>Kinds <input name="kinds" placeholder="1, 1111, 1621"{{with $edit}} value="{{scopeList . "kinds"}}"{{end}}></label>
178diff --git a/scripts/agent-grant.test.mjs b/scripts/agent-grant.test.mjs
179index 6718d41..6c0b924 100644
180--- a/scripts/agent-grant.test.mjs
181+++ b/scripts/agent-grant.test.mjs
182@@ -151,3 +151,12 @@ test("tags dedupe rooms, repositories and kinds", () => {
183 const tags = Constructor.tags({name: " bot ", expires: dateAfter(10), rooms: "a a, b", repos: `${repoOwner}:r:read\n${repoOwner}:r:maintain`, kinds: "1 1 2", wiki: "", rate: ""}, agent);
184 assert.deepEqual(JSON.parse(JSON.stringify(tags)).filter(tag => tag[0] !== "expiration"), [["d", agent], ["p", agent], ["name", "bot"], ["room", "a"], ["room", "b"], ["repo", `${repoOwner}:r:read`], ["k", "1"], ["k", "2"]]);
185 });
186+
187+test("a repository line accepts the propose level", async () => {
188+ const s = setup({repos: `${repoOwner}:tinyrelay:propose\n${repoOwner}:docs:read`, kinds: "1621, 1111"});
189+ await s.component.submit(s.form);
190+ const {event} = s.sent[0];
191+ assert.deepEqual(event.tags.filter(tag => tag[0] === "repo"), [["repo", `${repoOwner}:tinyrelay:propose`], ["repo", `${repoOwner}:docs:read`]]);
192+ const {Constructor} = setup();
193+ assert.throws(() => Constructor.tags({name: "bot", expires: dateAfter(10), rooms: "", repos: `${repoOwner}:tinyrelay:proposer`, kinds: "", wiki: "", rate: ""}, "d".repeat(64)), /propose, read or maintain/);
194+});

About

ownerc01503e8035f
visibilitypublic
head615c53c54955

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs