| 1 | diff --git a/internal/community/agents.go b/internal/community/agents.go |
| 2 | index 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) |
| 75 | diff --git a/internal/gates/agents_test.go b/internal/gates/agents_test.go |
| 76 | index 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 | } |
| 109 | diff --git a/internal/webui/agents_test.go b/internal/webui/agents_test.go |
| 110 | index 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 * 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 owner pubkey:identifier:read 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=""` + agentActive + `""><button>Pause</button></rpc-form>`, |
| 139 | `<rpc-form method="revokeagent" refresh><input type="hidden" name="param" value=""` + agentActive + `""><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 * ttl=7">` + agentSite + ` ttl=30 encrypted |
| 149 | *</textarea>`, |
| 150 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 151 | index 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]); |
| 165 | diff --git a/internal/webui/manage.html b/internal/webui/manage.html |
| 166 | index 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 owner pubkey:identifier:maintain">{{with $edit}}{{repoLines .}}{{end}}</textarea></label> |
| 174 | +<label>Repositories <textarea name="repos" placeholder="owner pubkey:identifier:propose owner pubkey:identifier:read 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 * 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> |
| 178 | diff --git a/scripts/agent-grant.test.mjs b/scripts/agent-grant.test.mjs |
| 179 | index 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 | +}); |