Give the agent's token write scopes and the agents write team (issue 948, ADR 0398 §1)

This commit is contained in:
fix-forge-awg
2026-10-16 21:25:59 +02:00
parent 8d012fe4e2
commit b0f86671b6
4 changed files with 180 additions and 30 deletions
+9 -7
View File
@@ -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-<node>`. The
An agent clones, fetches and pushes branches of the repositories as a forge account of its own, `agent-<node>`. 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-<node>` 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`.
+7 -7
View File
@@ -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-<node>`, 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-<node>`, 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-<node>), 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{
+72 -10
View File
@@ -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 {
+92 -6
View File
@@ -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"])
}
}