Lift the agent account code into internal/forgeaccount for the forge-account provider (issue 955 F1)

This commit is contained in:
2026-10-16 22:41:11 +02:00
parent 017b729bc9
commit c8dfbdc813
2 changed files with 158 additions and 108 deletions
+13 -108
View File
@@ -17,6 +17,7 @@ import (
"golang.org/x/crypto/nacl/box"
"gitea/internal/forgeaccount"
"gitea/internal/forgehttp"
)
@@ -31,7 +32,7 @@ const (
// TokenName is the token's name in the agent account's list, found and deleted by it at the next mint.
TokenName = "mesh-agent-read"
// AccountPrefix names an agent's account for its node (ADR 0365 §1).
AccountPrefix = "agent-"
AccountPrefix = forgeaccount.AccountPrefix
// vaultModule is the vault's module: its account is `<node>.mesh-vault` (ADR 0259 §8's runs-as).
vaultModule = "mesh-vault"
// deadline bounds one call, inside the vault's ask.
@@ -43,22 +44,12 @@ const (
// 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"}
// The agents team and its shape are the forgeaccount package's (ADR 0398 §1).
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"
TeamOrg = forgeaccount.TeamOrg
TeamName = forgeaccount.TeamName
)
// 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}$`)
// fromTheVault refuses every caller but the vault's own account (ADR 0349 clause 10): whoever else could call this
@@ -206,10 +197,10 @@ func (m *Minter) Answer(ask Ask) (Answer, error) {
continue
}
account := AccountPrefix + h.Node
if err := m.ensureAccount(ctx, account); err != nil {
if err := m.accounts().EnsureAccount(ctx, account); err != nil {
return Answer{}, err
}
if err := m.ensureInTeam(ctx, account); err != nil {
if err := m.accounts().EnsureInTeam(ctx, account); err != nil {
return Answer{}, err
}
token, err := m.mint(ctx, account)
@@ -274,91 +265,6 @@ func credentialLines(account, token string, origins []string) (string, error) {
return strings.Join(lines, "\n") + "\n", nil
}
// gitea's account as the admin reads it.
type forgeUser struct {
IsAdmin bool `json:"is_admin"`
Active bool `json:"active"`
ProhibitLogin bool `json:"prohibit_login"`
Restricted bool `json:"restricted"`
}
// 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. 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)
switch {
case errors.Is(err, forgehttp.ErrNotFound):
password := m.password()
err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/admin/users", map[string]any{
"username": account, "email": account + "@noreply.localhost", "password": password,
"must_change_password": false, "visibility": "private",
}, nil)
password = ""
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 yet", account)
return nil
case err != nil:
return fmt.Errorf("the account %s could not be read: %w", account, err)
}
if u.IsAdmin || !u.Active || u.ProhibitLogin {
if err := m.Forge.SendJSON(ctx, http.MethodPatch, "/api/v1/admin/users/"+account, map[string]any{
"login_name": account, "source_id": 0, "admin": false, "active": true, "prohibit_login": false,
}, nil); err != nil {
return fmt.Errorf("the account %s could not be made a plain account again: %w", account, err)
}
m.say("the account %s was changed by hand (administrator %t, active %t, login prohibited %t); made a plain account again",
account, u.IsAdmin, u.Active, u.ProhibitLogin)
}
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"`
@@ -392,13 +298,9 @@ func (m *Minter) mint(ctx context.Context, account string) (string, error) {
return strings.TrimSpace(made.SHA1), nil
}
func (m *Minter) password() string {
if m.Password != nil {
return m.Password()
}
b := make([]byte, 24)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
// accounts is the forge's accounts as this minter reaches them (ADR 0400 §3: one place says what an agent's account is).
func (m *Minter) accounts() *forgeaccount.Accounts {
return &forgeaccount.Accounts{Forge: m.Forge, Password: m.Password, Log: m.Log}
}
func (m *Minter) say(format string, a ...any) {
@@ -408,3 +310,6 @@ func (m *Minter) say(format string, a ...any) {
}
fmt.Fprintf(os.Stderr, format+"\n", a...)
}
// forgeUser is gitea's account as the admin reads it (the forgeaccount package's).
type forgeUser = forgeaccount.User
@@ -0,0 +1,145 @@
// Package forgeaccount is the forge accounts the mesh makes for its agents (novox/hq ADR 0365 clauses 1 and 3, ADR
// 0398 §1, ADR 0399, ADR 0400; issue 955): an account of the agent's own, no administrator, in the agents write
// team. Both the token tool (cmd/agent-token) and the forge-account provider use it, so there is one place that says
// what an agent's account is.
package forgeaccount
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/http"
"os"
"gitea/internal/forgehttp"
)
const (
// AccountPrefix names an agent's account for its node (ADR 0365 §1).
AccountPrefix = "agent-"
// 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"},
}
}
// User is gitea's account as the admin reads it.
type User struct {
IsAdmin bool `json:"is_admin"`
Active bool `json:"active"`
ProhibitLogin bool `json:"prohibit_login"`
Restricted bool `json:"restricted"`
}
// Accounts makes and keeps the agents' accounts with the administrator's login.
type Accounts struct {
Forge forgehttp.Client
// Password makes the password an account is created with: random, sent once and kept nowhere.
Password func() string
Log func(format string, a ...any)
}
// 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. Its team is EnsureInTeam's.
func (m *Accounts) EnsureAccount(ctx context.Context, account string) error {
var u User
err := m.Forge.GetJSON(ctx, "/api/v1/users/"+account, &u)
switch {
case errors.Is(err, forgehttp.ErrNotFound):
password := m.password()
err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/admin/users", map[string]any{
"username": account, "email": account + "@noreply.localhost", "password": password,
"must_change_password": false, "visibility": "private",
}, nil)
password = ""
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 yet", account)
return nil
case err != nil:
return fmt.Errorf("the account %s could not be read: %w", account, err)
}
if u.IsAdmin || !u.Active || u.ProhibitLogin {
if err := m.Forge.SendJSON(ctx, http.MethodPatch, "/api/v1/admin/users/"+account, map[string]any{
"login_name": account, "source_id": 0, "admin": false, "active": true, "prohibit_login": false,
}, nil); err != nil {
return fmt.Errorf("the account %s could not be made a plain account again: %w", account, err)
}
m.say("the account %s was changed by hand (administrator %t, active %t, login prohibited %t); made a plain account again",
account, u.IsAdmin, u.Active, u.ProhibitLogin)
}
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 *Accounts) 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
}
func (m *Accounts) password() string {
if m.Password != nil {
return m.Password()
}
b := make([]byte, 24)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func (m *Accounts) say(format string, a ...any) {
if m.Log != nil {
m.Log(format, a...)
return
}
fmt.Fprintf(os.Stderr, format+"\n", a...)
}