diff --git a/modules/gitea/README.md b/modules/gitea/README.md index d1a3397b..bd4bd433 100644 --- a/modules/gitea/README.md +++ b/modules/gitea/README.md @@ -102,21 +102,23 @@ changed under the commit refused; a commit over its size limits refused; protect forge matches them; a code search stopped at its deadline; every malformed ask refused; and each read summarised. -## Each agent's read credential (novox/hq ADR 0365 §1, §3–§5, issue 737) +## Each agent's credential (novox/hq ADR 0365 §1, §3–§5, issue 737; ADR 0398 §1, issue 948) -An agent clones and fetches the repositories it may read as a forge account of its own, `agent-`. The +An agent clones, fetches and pushes branches of the repositories as a forge account of its own, `agent-`. The module offers the provision `gitea-read`, whose credential its software issues (ADR 0305) one per holder (issue 749): the Go bundle `cmd/agent-token` serves `gitea_agent_token`, which the vault alone asks (ADR 0349 clause 10; any other caller is refused and nothing is minted). For each holder the vault names that is the claude-code module on a machine, it - makes the account `agent-` with the administrator's login, when there is none: no administrator, a random - password sent once and kept nowhere, and **in no team** — it reads public repositories until the operator adds it - to a team that reads more (§3). An account found made an administrator, inactive or barred from logging in is + password sent once and kept nowhere. It is added to the team `agents` of the organisation `novox`, which the + administrator's login makes when there is none and puts back to its shape when found changed: write on code, issues + and pull requests of every repository, no right to make repositories (the shape of the forge tools' team). The + operator's read teams (`agent-read-*`, ADR 0365 §3) are `cmd/agent-teams`'s and stay. An account found made an administrator, inactive or barred from logging in is made a plain account again; -- deletes the account's token by the name `mesh-agent-read` (by its id, every one of them) and mints one whose one - scope is `read:repository`: whatever team it is put in, the forge refuses it a push, a comment and any token or - administration of its own (§5). Every ask mints anew; that is its rotation; +- deletes the account's token by the name `mesh-agent-read` (by its id, every one of them) and mints one whose + scopes are `write:repository` and `write:issue`: the forge refuses it any token or administration of its own + (§5), and the main branch of every repository takes no push from it (ADR 0398 §2). Every ask mints anew; that is its rotation; - answers git's credential-store lines for it — the forge's public origin, read from `origins` (the route's name), and on the forge's own machine its address there too — sealed to the key the vault made for the call. A holder of another module is not answered and said under `skipped`. diff --git a/modules/gitea/cmd/agent-token/main.go b/modules/gitea/cmd/agent-token/main.go index 92f5a6d8..0303b670 100644 --- a/modules/gitea/cmd/agent-token/main.go +++ b/modules/gitea/cmd/agent-token/main.go @@ -1,10 +1,10 @@ -// agent-token: the forge's read credential for each node's agent (novox/hq ADR 0365 clauses 1, 3, 4 and 5; issue -// 737). A process the node's runtime launches and speaks MCP over stdio to, beside the module's other bundles. +// agent-token: the forge's credential for each node's agent (novox/hq ADR 0365 clauses 1, 3, 4 and 5, ADR 0398 §1; issues +// 737, 948). A process the node's runtime launches and speaks MCP over stdio to, beside the module's other bundles. // // It serves one tool, gitea_agent_token, which the vault alone asks (ADR 0349 clause 10, issue 749): for each holder // of the provision `gitea-read` the vault names — the claude-code module on one machine — it makes that machine's -// agent a forge account of its own, `agent-`, no administrator and in no team, mints it a token whose one -// scope is `read:repository`, and answers the git credential lines for it, each holder's sealed to the key the vault +// agent a forge account of its own, `agent-`, no administrator and a member of the `agents` write team, mints it a token whose +// scopes are `write:repository` and `write:issue`, and answers the git credential lines for it, each holder's sealed to the key the vault // made for the call. Asked again, it mints again and deletes the account's token from before: that is its rotation. // // stdout is the protocol; it says what it says on stderr, and never a token or the admin's password. @@ -33,10 +33,10 @@ func main() { func Tools(minter func() (*Minter, error)) []stdio.Tool { return []stdio.Tool{{ Name: ToolName, - Description: "The forge's read credential for each node's agent (novox/hq ADR 0365), answered to the vault " + + Description: "The forge's credential for each node's agent (novox/hq ADR 0365, ADR 0398), answered to the vault " + "alone: for each holder named, a forge account of the node's agent's own (agent-), no administrator " + - "and in no team, and a token of scope read:repository minted for it anew, its git credential lines " + - "sealed to seal_to. Any other caller is refused and nothing is minted.", + "and a member of the agents team (write on every repository), and a token of scopes write:repository and " + + "write:issue minted for it anew, its git credential lines sealed to seal_to. Any other caller is refused and nothing is minted.", Input: map[string]any{ "type": "object", "properties": map[string]any{ diff --git a/modules/gitea/cmd/agent-token/token.go b/modules/gitea/cmd/agent-token/token.go index 33e8de9e..a827312f 100644 --- a/modules/gitea/cmd/agent-token/token.go +++ b/modules/gitea/cmd/agent-token/token.go @@ -23,7 +23,7 @@ import ( const ( // ToolName is the issuing tool the vault asks (the offer's credential tool in module.json). ToolName = "gitea_agent_token" - // Provision is what the tool issues: the forge's read credential, one per agent. + // Provision is what the tool issues: the forge's agent credential (named gitea-read from before it wrote), one per agent. Provision = "gitea-read" // Holder is the one consuming module answered: the agent's own module, which places the credential in the // agent's home (ADR 0365 §4, ADR 0266). A holder of another module is not answered and keeps nothing. @@ -38,10 +38,26 @@ const ( deadline = 45 * time.Second ) -// TokenScopes are the only scopes an agent's token carries (ADR 0365 §4, §5): it reads repositories and nothing -// else, so the forge refuses it a push, a comment, a token of its own and any administration — whatever team the -// account is in. -var TokenScopes = []string{"read:repository"} +// TokenScopes are the only scopes an agent's token carries (ADR 0398 §1, issue 948): it writes repositories and +// issues and nothing else, so the forge refuses it a token of its own and any administration. Where it may write is +// the agents team's (a write team on every repository); main takes no push whatever the token (ADR 0398 §2). +var TokenScopes = []string{"write:repository", "write:issue"} + +const ( + // TeamOrg is the organisation whose repositories are the mesh's. + TeamOrg = "novox" + // TeamName is the one team every agent's account is in (ADR 0398 §1). + TeamName = "agents" +) + +// teamShape is what the agents team grants: write on code, issues and pull requests of every repository of the +// organisation and no more, the shape of the forge tools' team (tools-account.ts). +func teamShape() map[string]any { + return map[string]any{ + "name": TeamName, "permission": "write", "includes_all_repositories": true, "can_create_org_repo": false, + "units_map": map[string]string{"repo.code": "write", "repo.issues": "write", "repo.pulls": "write"}, + } +} var nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) @@ -193,6 +209,9 @@ func (m *Minter) Answer(ask Ask) (Answer, error) { if err := m.ensureAccount(ctx, account); err != nil { return Answer{}, err } + if err := m.ensureInTeam(ctx, account); err != nil { + return Answer{}, err + } token, err := m.mint(ctx, account) if err != nil { return Answer{}, err @@ -208,7 +227,7 @@ func (m *Minter) Answer(ask Ask) (Answer, error) { return Answer{}, fmt.Errorf("the credential for %s could not be sealed: %w", account, err) } out.Holders = append(out.Holders, Sealed{Node: h.Node, Module: h.Module, Sealed: base64.StdEncoding.EncodeToString(sealed)}) - m.say("minted a read token for %s (%s on %s); its token from before is deleted", account, h.Module, h.Node) + m.say("minted a write token for %s (%s on %s); its token from before is deleted", account, h.Module, h.Node) } return out, nil } @@ -264,8 +283,7 @@ type forgeUser struct { } // ensureAccount makes the agent's account, plain, when there is none, and puts back what was changed by hand: never -// an administrator, active, able to use its token. It adds it to no team: what it may read beyond public -// repositories is the operator's to grant (ADR 0365 §3). +// an administrator, active, able to use its token. Its team is ensureInTeam's. func (m *Minter) ensureAccount(ctx context.Context, account string) error { var u forgeUser err := m.Forge.GetJSON(ctx, "/api/v1/users/"+account, &u) @@ -280,7 +298,7 @@ func (m *Minter) ensureAccount(ctx context.Context, account string) error { if err != nil { return fmt.Errorf("the account %s could not be made: %w", account, err) } - m.say("made the forge account %s for that machine's agent, in no team", account) + m.say("made the forge account %s for that machine's agent, in no team yet", account) return nil case err != nil: return fmt.Errorf("the account %s could not be read: %w", account, err) @@ -297,6 +315,50 @@ func (m *Minter) ensureAccount(ctx context.Context, account string) error { return nil } +// ensureInTeam makes the agents team when there is none, puts a team that is there back to its shape, and adds the +// account to it; an account already in it stays (ADR 0398 §1, issue 948). +func (m *Minter) ensureInTeam(ctx context.Context, account string) error { + var id int64 + for page := 1; page <= 20 && id == 0; page++ { + var teams []struct { + ID int64 `json:"id"` + Name string `json:"name"` + } + if err := m.Forge.GetJSON(ctx, fmt.Sprintf("/api/v1/orgs/%s/teams?limit=50&page=%d", TeamOrg, page), &teams); err != nil { + return fmt.Errorf("the teams of %s could not be read: %w", TeamOrg, err) + } + for _, t := range teams { + if t.Name == TeamName { + id = t.ID + } + } + if len(teams) < 50 { + break + } + } + if id != 0 { + if err := m.Forge.SendJSON(ctx, http.MethodPatch, fmt.Sprintf("/api/v1/teams/%d", id), teamShape(), nil); err != nil { + return fmt.Errorf("the team %s could not be put back to its shape: %w", TeamName, err) + } + } else { + var made struct { + ID int64 `json:"id"` + } + if err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/orgs/"+TeamOrg+"/teams", teamShape(), &made); err != nil { + return fmt.Errorf("the team %s could not be made: %w", TeamName, err) + } + id = made.ID + m.say("made the forge team %s in %s: write on every repository", TeamName, TeamOrg) + } + if id == 0 { + return fmt.Errorf("the forge answered no id for the team %s", TeamName) + } + if err := m.Forge.SendJSON(ctx, http.MethodPut, fmt.Sprintf("/api/v1/teams/%d/members/%s", id, account), nil, nil); err != nil { + return fmt.Errorf("the account %s could not be added to the team %s: %w", account, TeamName, err) + } + return nil +} + type forgeToken struct { ID int64 `json:"id"` Name string `json:"name"` @@ -304,7 +366,7 @@ type forgeToken struct { } // mint deletes every token of the account by this module's name, by its id (a name held twice is cleared, not -// refused), and mints one with the read scope alone. +// refused), and mints one with the two write scopes alone. func (m *Minter) mint(ctx context.Context, account string) (string, error) { var held []forgeToken if err := m.Forge.GetJSON(ctx, "/api/v1/users/"+account+"/tokens?limit=50", &held); err != nil { diff --git a/modules/gitea/cmd/agent-token/token_test.go b/modules/gitea/cmd/agent-token/token_test.go index 3f12dc4d..9fbdfb63 100644 --- a/modules/gitea/cmd/agent-token/token_test.go +++ b/modules/gitea/cmd/agent-token/token_test.go @@ -33,13 +33,20 @@ type fakeForge struct { made map[string]map[string]any minted map[string][]string // account -> scopes of its last mint failMint bool + // teams by name; members by team name (issue 948). + teams map[string]map[string]any + teamIDs map[string]int64 + members map[string][]string } func newFakeForge() *fakeForge { return &fakeForge{users: map[string]*forgeUser{}, tokens: map[string][]forgeToken{}, made: map[string]map[string]any{}, - minted: map[string][]string{}, next: 1} + minted: map[string][]string{}, next: 1, + teams: map[string]map[string]any{}, teamIDs: map[string]int64{}, members: map[string][]string{}} } +var teamPath = regexp.MustCompile(`^/api/v1/teams/(\d+)(?:/members/([^/]+))?$`) + var tokenPath = regexp.MustCompile(`^/api/v1/users/([^/]+)/tokens(?:/(\d+))?$`) func (f *fakeForge) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -80,6 +87,40 @@ func (f *fakeForge) ServeHTTP(w http.ResponseWriter, r *http.Request) { u.Active, _ = in["active"].(bool) u.ProhibitLogin, _ = in["prohibit_login"].(bool) reply(200, map[string]any{}) + case r.URL.Path == "/api/v1/orgs/novox/teams": + if r.Method == http.MethodGet { + var list []map[string]any + for name, id := range f.teamIDs { + list = append(list, map[string]any{"id": id, "name": name}) + } + reply(200, list) + return + } + name, _ := in["name"].(string) + f.teams[name] = in + f.teamIDs[name] = f.next + f.next++ + reply(201, map[string]any{"id": f.teamIDs[name], "name": name}) + case teamPath.MatchString(r.URL.Path): + m := teamPath.FindStringSubmatch(r.URL.Path) + for name, id := range f.teamIDs { + if fmt.Sprint(id) != m[1] { + continue + } + if m[2] == "" && r.Method == http.MethodPatch { + f.teams[name] = in + reply(200, map[string]any{"id": id}) + return + } + if m[2] != "" && r.Method == http.MethodPut { + if !slices.Contains(f.members[name], m[2]) { + f.members[name] = append(f.members[name], m[2]) + } + reply(204, nil) + return + } + } + reply(404, map[string]any{"message": "no such team"}) case tokenPath.MatchString(r.URL.Path): m := tokenPath.FindStringSubmatch(r.URL.Path) account := m[1] @@ -199,7 +240,7 @@ func TestOnlyTheVaultIsAnsweredAndNothingIsMintedForAnyoneElse(t *testing.T) { } } -func TestEachAgentGetsAnAccountOfItsOwnAndAReadTokenSealedForItAlone(t *testing.T) { +func TestEachAgentGetsAnAccountOfItsOwnAndAWriteTokenSealedForItAlone(t *testing.T) { h := newHarness(t) ask, err := readAsk(h.args(HolderAsk{"anchor", "claude-code"}, HolderAsk{"laptop", "claude-code"}, HolderAsk{"laptop", "lab"})) if err != nil { @@ -240,15 +281,23 @@ func TestEachAgentGetsAnAccountOfItsOwnAndAReadTokenSealedForItAlone(t *testing. if made := h.forge.made[account]; made["must_change_password"] != false || made["password"] == "" { t.Errorf("%s was made with %v", account, made) } - if !slices.Equal(h.forge.minted[account], []string{"read:repository"}) { - t.Errorf("%s's token carries %v, want read:repository alone", account, h.forge.minted[account]) + if !slices.Equal(h.forge.minted[account], []string{"write:repository", "write:issue"}) { + t.Errorf("%s's token carries %v, want write:repository and write:issue alone (issue 948)", account, h.forge.minted[account]) } } for _, c := range h.forge.calls { - if strings.Contains(c, "/teams") || strings.Contains(c, "/orgs/") || strings.Contains(c, "collaborators") { - t.Errorf("an agent's account was given access (%s): it starts in no team, the operator grants (ADR 0365 §3)", c) + if strings.Contains(c, "collaborators") { + t.Errorf("an agent's account was given access one repository at a time (%s) (issue 948)", c) } } + for _, account := range []string{"agent-anchor", "agent-laptop"} { + if !slices.Contains(h.forge.members["agents"], account) { + t.Errorf("%s is not in the agents team: %v (issue 948, ADR 0398 §1)", account, h.forge.members) + } + } + if len(h.forge.members) != 1 { + t.Errorf("an agent was put in a team but agents: %v", h.forge.members) + } if _, made := h.forge.users["agent-laptop-lab"]; made || len(h.forge.users) != 2 { t.Errorf("accounts made: %v", h.forge.users) } @@ -374,3 +423,40 @@ func tokenIn(t *testing.T, lines, account string) string { } return all[0][1] } + +func TestTheAgentsTeamIsMadeWithWriteOnEveryRepositoryAndReusedWhenThere(t *testing.T) { + h := newHarness(t) + ask, _ := readAsk(h.args(HolderAsk{"laptop", "claude-code"})) + if _, err := h.minter.Answer(ask); err != nil { + t.Fatal(err) + } + team := h.forge.teams["agents"] + if team == nil { + t.Fatalf("no agents team was made (issue 948): %v", h.forge.teams) + } + if team["permission"] != "write" || team["includes_all_repositories"] != true || team["can_create_org_repo"] != false { + t.Errorf("the agents team is %v, want write on all repositories and no repository creation", team) + } + units, _ := team["units_map"].(map[string]any) + for _, u := range []string{"repo.code", "repo.issues", "repo.pulls"} { + if units[u] != "write" { + t.Errorf("unit %s is %v, want write", u, units[u]) + } + } + id := h.forge.teamIDs["agents"] + // Changed by hand, then asked again for another node: reused, put back, the new account added. + h.forge.teams["agents"] = map[string]any{"name": "agents", "permission": "read"} + ask, _ = readAsk(h.args(HolderAsk{"laptop", "claude-code"}, HolderAsk{"anchor", "claude-code"})) + if _, err := h.minter.Answer(ask); err != nil { + t.Fatal(err) + } + if h.forge.teamIDs["agents"] != id || len(h.forge.teamIDs) != 1 { + t.Errorf("the team was made again: %v", h.forge.teamIDs) + } + if h.forge.teams["agents"]["permission"] != "write" { + t.Errorf("the team was not put back to write: %v", h.forge.teams["agents"]) + } + if !slices.Equal(h.forge.members["agents"], []string{"agent-laptop", "agent-anchor"}) { + t.Errorf("members %v, each once", h.forge.members["agents"]) + } +}