- commit
- 615c53c549552527b365ed0cce361473c5106b1a
- parent
- 56953bfd17dc1dc175909d07aeffe943701a4b9c
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 29 | +++++++++++++++++++++++------ |
| M | internal/ | 16 | ++++++++++++++-- |
| M | internal/ | 9 | ++++++--- |
| M | internal/ | 4 | ++-- |
| M | internal/ | 2 | +- |
| M | scripts/ | 9 | +++++++++ |
@@ -32,14 +32,23 @@ const (
3232 3333 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 '{}');`3434 35-// AgentRepo is one repository an agent may work in. Level is "read" or36-// "maintain"; maintain includes read.35+// AgentRepo is one repository an agent may work in. Level is "propose",36+// "read" or "maintain". Propose and read publish the same kinds, but each37+// event from a propose grant is a proposal that waits for a maintainer's38+// decision; maintain adds status changes and pushes.3739 type AgentRepo struct {3840 Owner string `json:"owner"`3941 Identifier string `json:"identifier"`4042 Level string `json:"level"`4143 }4244 45+// Repository grant levels.46+const (47+ AgentRepoPropose = "propose"48+ AgentRepoRead = "read"49+ AgentRepoMaintain = "maintain"50+)51+4352 // Long task grants. AgentJobsRequest lets the agent publish job requests,4453 // AgentJobsServe lets it answer requests the relay holds with results and4554 // feedback, and AgentJobsBoth does both.@@ -96,7 +105,15 @@ func (g AgentGrant) Active(now int64) bool {
96105 // Maintains reports whether the grant currently makes the agent a maintainer97106 // of one repository: it is active and holds maintain on that exact repository.98107 func (g AgentGrant) Maintains(owner, identifier string, now int64) bool {99- return g.Active(now) && g.RepoLevel(owner, identifier) == "maintain"108+ return g.Active(now) && g.RepoLevel(owner, identifier) == AgentRepoMaintain109+}110+111+// Proposes reports whether the grant holds propose on one repository, so112+// the agent's issues, pull requests, patches and comments there are113+// proposals. A paused, revoked or expired grant keeps counting, so an114+// agent's pending events do not surface when its grant ends.115+func (g AgentGrant) Proposes(owner, identifier string) bool {116+ return g.RepoLevel(owner, identifier) == AgentRepoPropose100117 }101118 102119 // AllowsKind reports whether the agent may publish this kind. Profiles, relay@@ -245,7 +262,7 @@ func (g AgentGrant) Check(e event.Event, now int64) error {
245262 if level == "" {246263 return fmt.Errorf("restricted: agent grant does not allow repository %s:%s", coordinate[0], coordinate[1])247264 }248- if gitStatusKind(e.Kind) && level != "maintain" {265+ if gitStatusKind(e.Kind) && level != AgentRepoMaintain {249266 return fmt.Errorf("restricted: agent grant does not allow status changes in repository %s:%s", coordinate[0], coordinate[1])250267 }251268 }@@ -344,7 +361,7 @@ func ParseAgentGrant(e event.Event, now int64) (AgentGrant, error) {
344361 for _, value := range event.TagValues(e, "repo") {345362 repo, ok := parseAgentRepo(value)346363 if !ok {347- return AgentGrant{}, errors.New("invalid: agent grant repo tag must be owner:identifier:read or owner:identifier:maintain")364+ return AgentGrant{}, errors.New("invalid: agent grant repo tag must be owner:identifier followed by :propose, :read or :maintain")348365 }349366 if grant.RepoLevel(repo.Owner, repo.Identifier) == "" {350367 grant.Scope.Repos = append(grant.Scope.Repos, repo)@@ -408,7 +425,7 @@ func parseAgentRepo(value string) (AgentRepo, bool) {
408425 return AgentRepo{}, false409426 }410427 level := value[last+1:]411- if level != "read" && level != "maintain" {428+ if level != AgentRepoPropose && level != AgentRepoRead && level != AgentRepoMaintain {412429 return AgentRepo{}, false413430 }414431 parts := strings.SplitN(value[:last], ":", 2)@@ -132,15 +132,27 @@ func TestAgentGrantScopesKindsRoomsAndRepositories(t *testing.T) {
132132 if err := f.gate.Write(ctx, signed(t, agentSecret, 1630, agentNow, [][]string{{"a", address}}, "status"), session, agentNow); err != nil {133133 t.Fatalf("maintainer status rejected: %v", err)134134 }135+ // Propose publishes the same kinds as read; status changes still need136+ // maintain.137+ 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"}))138+ for _, kind := range []int{1621, 1618, 1617, 1111} {139+ if err := f.gate.Write(ctx, signed(t, agentSecret, kind, agentNow, [][]string{{"a", address}}, "proposal"), session, agentNow); err != nil {140+ t.Fatalf("kind %d in proposable repository rejected: %v", kind, err)141+ }142+ }143+ expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, 1630, agentNow, [][]string{{"a", address}}, "status"), session, agentNow), "status changes")144+ 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") {145+ t.Fatalf("unknown level accepted: %v", err)146+ }135147 // Wiki access carries the wiki kinds without listing them.136- f.grant(t, agentNow+2, grantTags(f.agent, agentNow+3600, []string{"wiki", "propose"}))148+ f.grant(t, agentNow+3, grantTags(f.agent, agentNow+3600, []string{"wiki", "propose"}))137149 for _, kind := range []int{event.KIND_WIKI_ARTICLE, event.KIND_WIKI_MERGE} {138150 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 {139151 t.Fatalf("wiki kind %d rejected for propose: %v", kind, err)140152 }141153 }142154 expectRestricted(t, f.gate.Write(ctx, signed(t, agentSecret, event.KIND_WIKI_REDIRECT, agentNow, [][]string{{"d", "notes"}}, ""), session, agentNow), "kind 30819")143- f.grant(t, agentNow+3, grantTags(f.agent, agentNow+3600, []string{"wiki", "edit"}))155+ f.grant(t, agentNow+4, grantTags(f.agent, agentNow+3600, []string{"wiki", "edit"}))144156 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 {145157 t.Fatalf("redirect rejected for edit: %v", err)146158 }@@ -34,7 +34,7 @@ func (b *agentsBackend) Query(_ context.Context, method string, params []json.Ra
3434 scope := func(rooms []string, repos []map[string]any, wiki string, kinds []int) map[string]any {3535 return map[string]any{"rooms": rooms, "repos": repos, "wiki": wiki, "kinds": kinds, "rate": 60}3636 }37- hermes := scope([]string{"build", "agents"}, []map[string]any{{"owner": owner, "identifier": "tinyrelay", "level": "maintain"}}, "propose", []int{9, 1111, 1621})37+ 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})3838 hermes["sites"] = []map[string]any{{"label": agentSite, "ttl": 30, "encrypted": true}, {"label": "*"}}3939 agents := []any{4040 map[string]any{"pubkey": agentActive, "owner": owner, "name": "hermes", "expires": now + 86400*30, "paused": false, "revoked": 0, "lastEvent": now - 120, "scope": hermes},@@ -92,7 +92,7 @@ func TestAgentsPageRendersTableCardsActivityAndGrantForm(t *testing.T) {
9292 `<a href="/manage/agents" aria-current="page">/agents</a>`,9393 `<table id="rows">`,9494 `<a href="#agent-` + agentActive + `">hermes</a>`,95- `rooms: build, agents | repos: tinyrelay (maintain) | wiki: propose | kinds: 9, 1111, 1621 | sites: ` + agentSite + `, *`,95+ `rooms: build, agents | repos: tinyrelay (maintain), docs (propose) | wiki: propose | kinds: 9, 1111, 1621 | sites: ` + agentSite + `, *`,9696 `<tr><th>sites</th><td><code>` + agentSite + `</code> 30 days encrypted<br><code>*</code></td></tr>`,9797 `<tr><th>sites</th><td>none</td></tr>`,9898 `<textarea name="sites" placeholder="npub1... ttl=30 encrypted * ttl=7"></textarea>`,@@ -103,6 +103,8 @@ func TestAgentsPageRendersTableCardsActivityAndGrantForm(t *testing.T) {
103103 `<agent-card id="agent-` + agentActive + `">`,104104 `<h3>hermes <small><span data-state="active">active</span> | signed by you | expires `,105105 `<code>tinyrelay</code> maintain, owner <nostr-name pubkey="` + backend.policy.Owner + `"`,106+ `<br><code>docs</code> propose, owner <nostr-name pubkey="` + backend.policy.Owner + `"`,107+ `<textarea name="repos" placeholder="owner pubkey:identifier:propose owner pubkey:identifier:read owner pubkey:identifier:maintain">`,106108 `<th>rate</th><td>60 events per minute</td>`,107109 `<rpc-form method="pauseagent" refresh><input type="hidden" name="param" value=""` + agentActive + `""><button>Pause</button></rpc-form>`,108110 `<rpc-form method="revokeagent" refresh><input type="hidden" name="param" value=""` + agentActive + `""><button>Revoke</button></rpc-form>`,@@ -239,7 +241,8 @@ func TestAgentsPageEditPrefillsTheGrantForm(t *testing.T) {
239241 `<option value="paste" selected>paste a public key</option>`,240242 `<input name="pubkey" pattern="[0-9a-f]{64}" placeholder="64 hex characters" value="` + agentActive + `">`,241243 `placeholder="build, agents" value="build, agents">`,242- `>` + strings.Repeat("a", 64) + `:tinyrelay:maintain</textarea>`,244+ `>` + strings.Repeat("a", 64) + `:tinyrelay:maintain245+` + strings.Repeat("a", 64) + `:docs:propose</textarea>`,243246 `<option value="propose" selected>propose</option>`,244247 `<textarea name="sites" placeholder="npub1... ttl=30 encrypted * ttl=7">` + agentSite + ` ttl=30 encrypted245248 *</textarea>`,@@ -532,8 +532,8 @@
532532 }533533 const seenRepos = new Set();534534 for (const line of text("repos").split(/\r?\n/).map(line => line.trim()).filter(Boolean)) {535- const match = /^([0-9a-f]{64}):(.+):(read|maintain)$/.exec(line);536- if (!match || match[2].length > 256) throw Error("Repositories are owner pubkey:identifier:read or maintain, one per line.");535+ const match = /^([0-9a-f]{64}):(.+):(propose|read|maintain)$/.exec(line);536+ if (!match || match[2].length > 256) throw Error("Repositories are owner pubkey:identifier:propose, read or maintain, one per line.");537537 if (seenRepos.has(match[1] + ":" + match[2])) continue;538538 seenRepos.add(match[1] + ":" + match[2]);539539 tags.push(["repo", line]);@@ -69,7 +69,7 @@
6969 <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>7070 <label>Public key <input name="pubkey" pattern="[0-9a-f]{64}" placeholder="64 hex characters"{{with $edit}} value="{{str (index (asMap .) "pubkey")}}"{{end}}></label>7171 <label>Rooms <input name="rooms" placeholder="build, agents"{{with $edit}} value="{{scopeList . "rooms"}}"{{end}}></label>72-<label>Repositories <textarea name="repos" placeholder="owner pubkey:identifier:read owner pubkey:identifier:maintain">{{with $edit}}{{repoLines .}}{{end}}</textarea></label>72+<label>Repositories <textarea name="repos" placeholder="owner pubkey:identifier:propose owner pubkey:identifier:read owner pubkey:identifier:maintain">{{with $edit}}{{repoLines .}}{{end}}</textarea></label>7373 <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>7474 <label>Sites <textarea name="sites" placeholder="npub1... ttl=30 encrypted * ttl=7">{{with $edit}}{{siteLines .}}{{end}}</textarea></label>7575 <label>Kinds <input name="kinds" placeholder="1, 1111, 1621"{{with $edit}} value="{{scopeList . "kinds"}}"{{end}}></label>@@ -151,3 +151,12 @@ test("tags dedupe rooms, repositories and kinds", () => {
151151 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);152152 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"]]);153153 });154+155+test("a repository line accepts the propose level", async () => {156+ const s = setup({repos: `${repoOwner}:tinyrelay:propose\n${repoOwner}:docs:read`, kinds: "1621, 1111"});157+ await s.component.submit(s.form);158+ const {event} = s.sent[0];159+ assert.deepEqual(event.tags.filter(tag => tag[0] === "repo"), [["repo", `${repoOwner}:tinyrelay:propose`], ["repo", `${repoOwner}:docs:read`]]);160+ const {Constructor} = setup();161+ assert.throws(() => Constructor.tags({name: "bot", expires: dateAfter(10), rooms: "", repos: `${repoOwner}:tinyrelay:proposer`, kinds: "", wiki: "", rate: ""}, "d".repeat(64)), /propose, read or maintain/);162+});