Feature 57 (config and provisioning): release the catalogue, the reviewed release branch merged with main, on top of main

This commit is contained in:
2026-10-16 09:42:45 +00:00
parent ee1b15d7f0
commit 0260ae92f6
646 changed files with 37611 additions and 1861 deletions
+36
View File
@@ -102,6 +102,42 @@ 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)
An agent clones and fetches the repositories it may read 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
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;
- 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`.
The vault keeps each as `provision/<forge's node>/gitea-read/<node>/claude-code` and hands it to that holder alone;
the claude-code module places it in the agent's home (ADR 0365 §4). The token is never logged, answered in the clear
or put in an error. `go test ./cmd/agent-token` runs it against a fake forge.
## The agents' read teams (novox/hq ADR 0365 §3, issue 758)
An agent's account reads more than public repositories only through a team, per repository, and only to read. Which
teams exist and who is in them is the operator's, and it is one trusted config value, `agent-read-teams`:
`{"<team>": {"repos": ["<owner>/<repo>", ...], "members": ["<node>", ...]}}`, a member `<node>` being the account
`agent-<node>`. The module renders it into `agent-teams.json` and runs `cmd/agent-teams` when it starts and
whenever that file changes. For each team it makes `agent-read-<team>` in the organisation of the first repository's
owner, with permission read and no other right, its repositories exactly the listed ones and its members exactly the
listed accounts (an account that does not exist yet is skipped and said). A team named `agent-read-*` that has left
the value is emptied, never deleted. A team without that prefix is never touched. There are no verbs: a change is a
change of the value. It says each change in one line on stdout, and never a secret. `go test ./cmd/agent-teams`
runs it against a fake forge.
## The package registry's retention (novox/hq ADR 0251 §4, to-be 51)
Every publish adds a version to the registry, and until this nothing removed one. The Go bundle
+42
View File
@@ -0,0 +1,42 @@
// agent-teams: the agents' read teams on the forge (novox/hq ADR 0365 clause 3, issue 758). A program the node's
// runtime runs once when the module starts and again whenever the module's config value `agent-read-teams` changes:
// it reads the file the mesh renders from that value, and makes the forge's teams named agent-read-* match it. Each
// team reads only; its repositories and its members (agent accounts, agent-<node>) are exactly the value's. It
// touches nothing outside the value: no team without the prefix, no repository or account of another kind.
//
// It says each change on stdout in one line, and never a secret.
package main
import (
"context"
"fmt"
"os"
"time"
"gitea/internal/forgehttp"
)
func main() {
forge, missing := forgehttp.FromEnv()
file := os.Getenv("MESH_AGENT_TEAMS_FILE")
if file == "" {
missing = append(missing, "MESH_AGENT_TEAMS_FILE")
}
if len(missing) > 0 {
fmt.Fprintf(os.Stderr, "agent-teams: %v not set by the manifest; nothing was changed\n", missing)
os.Exit(1)
}
value, err := ReadValue(file)
if err != nil {
fmt.Fprintln(os.Stderr, "agent-teams:", err)
os.Exit(1)
}
s := &Syncer{Forge: forge, Log: func(format string, a ...any) { fmt.Printf(format+"\n", a...) },
WaitFor: 3 * time.Minute, Poll: 5 * time.Second}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
if err := s.Sync(ctx, value); err != nil {
fmt.Fprintln(os.Stderr, "agent-teams:", err)
os.Exit(1)
}
}
+339
View File
@@ -0,0 +1,339 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"regexp"
"sort"
"strings"
"time"
"gitea/internal/forgehttp"
)
const (
// ConfigKey is the module's config value this program applies.
ConfigKey = "agent-read-teams"
// TeamPrefix names every team this program owns; a team without it is never touched (ADR 0365 §3).
TeamPrefix = "agent-read-"
// AccountPrefix names an agent's account for its node (cmd/agent-token AccountPrefix, ADR 0365 §1).
AccountPrefix = "agent-"
page = 50
)
var (
teamName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,40}$`)
nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
repoName = regexp.MustCompile(`^([A-Za-z0-9][A-Za-z0-9_.-]*)/([A-Za-z0-9_.-]+)$`)
)
// readUnits are the units a read team has, each at read: the forge's rights of a team that reads and nothing more.
var readUnits = map[string]string{
"repo.code": "read", "repo.issues": "read", "repo.pulls": "read", "repo.releases": "read", "repo.wiki": "read",
}
// Team is one read team of the value: its repositories (owner/repo, read only) and its members (nodes whose agent
// account, agent-<node>, is in the team).
type Team struct {
Repos []string `json:"repos"`
Members []string `json:"members"`
}
// Value is the config value: each team by the name it is given without the prefix.
type Value map[string]Team
// ReadValue reads the file the mesh renders from the config value. A file without the key is the empty value,
// which empties every read team.
func ReadValue(path string) (Value, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("the teams file cannot be read (%s): %w", path, err)
}
var file map[string]json.RawMessage
if err := json.Unmarshal(raw, &file); err != nil {
return nil, fmt.Errorf("the teams file %s is not JSON: %w", path, err)
}
var v Value
if r, ok := file[ConfigKey]; ok {
dec := json.NewDecoder(strings.NewReader(string(r)))
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return nil, fmt.Errorf("%s in %s is not {team: {repos, members}}: %w", ConfigKey, path, err)
}
}
return v, nil
}
// Syncer makes the forge's agent-read-* teams the value's.
type Syncer struct {
Forge forgehttp.Client
Log func(format string, a ...any)
WaitFor time.Duration // how long to wait for the forge to answer; none when zero
Poll time.Duration
}
type forgeTeam struct {
ID int64 `json:"id"`
Name string `json:"name"`
Permission string `json:"permission"`
}
type forgeRepo struct {
FullName string `json:"full_name"`
}
type forgeMember struct {
Login string `json:"login"`
}
func (s *Syncer) say(format string, a ...any) {
if s.Log != nil {
s.Log(format, a...)
}
}
// Sync applies the value. A team that cannot be applied is said and the others still are; the error names them.
func (s *Syncer) Sync(ctx context.Context, v Value) error {
if err := s.waitForForge(ctx); err != nil {
return err
}
orgs, err := s.orgs(ctx)
if err != nil {
return err
}
wanted := map[string]bool{}
var failed []string
names := make([]string, 0, len(v))
for n := range v {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
wanted[TeamPrefix+n] = true
if err := s.apply(ctx, n, v[n]); err != nil {
s.say("not applied: team %s%s: %v", TeamPrefix, n, err)
failed = append(failed, TeamPrefix+n)
}
}
for _, org := range orgs {
teams, err := s.teams(ctx, org)
if err != nil {
return err
}
for _, t := range teams {
if !strings.HasPrefix(t.Name, TeamPrefix) || wanted[t.Name] {
continue
}
if err := s.sync(ctx, org, t, nil, nil); err != nil {
s.say("not applied: team %s in %s: %v", t.Name, org, err)
failed = append(failed, t.Name)
}
}
}
if len(failed) > 0 {
return fmt.Errorf("not applied: %s", strings.Join(failed, ", "))
}
return nil
}
func (s *Syncer) waitForForge(ctx context.Context) error {
deadline := time.Now().Add(s.WaitFor)
for {
err := s.Forge.GetJSON(ctx, "/api/v1/version", nil)
if err == nil {
return nil
}
var se *forgehttp.StatusError
if errors.As(err, &se) || time.Now().After(deadline) || ctx.Err() != nil {
return fmt.Errorf("the forge is not reachable: %w", err)
}
time.Sleep(s.Poll)
}
}
// apply makes one team of the value.
func (s *Syncer) apply(ctx context.Context, name string, t Team) error {
if !teamName.MatchString(name) {
return fmt.Errorf("%q is not a team name", name)
}
if len(t.Repos) == 0 {
return errors.New("it lists no repository, so the organisation to make it in is not known")
}
org := ""
repos := map[string]bool{}
for _, r := range t.Repos {
m := repoName.FindStringSubmatch(r)
if m == nil {
return fmt.Errorf("%q is not owner/repo", r)
}
if org == "" {
org = m[1]
}
if m[1] != org {
return fmt.Errorf("%q is not in %s: a team's repositories are of one owner, the first's", r, org)
}
repos[r] = true
}
members := map[string]bool{}
for _, n := range t.Members {
if !nodeName.MatchString(n) {
return fmt.Errorf("%q is not a node's name", n)
}
members[AccountPrefix+n] = true
}
full := TeamPrefix + name
teams, err := s.teams(ctx, org)
if err != nil {
return err
}
var team *forgeTeam
for i := range teams {
if teams[i].Name == full {
team = &teams[i]
}
}
if team == nil {
var made forgeTeam
err := s.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/orgs/"+org+"/teams", map[string]any{
"name": full, "description": "Read-only team of agents' forge accounts (ADR 0365 §3); set by the gitea config value " + ConfigKey,
"permission": "read", "includes_all_repositories": false, "can_create_org_repo": false, "units_map": readUnits,
}, &made)
if err != nil {
return fmt.Errorf("the team could not be made: %w", err)
}
s.say("made the team %s in %s, reading only", full, org)
team = &made
} else if team.Permission != "read" {
if err := s.Forge.SendJSON(ctx, http.MethodPatch, fmt.Sprintf("/api/v1/teams/%d", team.ID), map[string]any{
"name": full, "permission": "read", "includes_all_repositories": false, "can_create_org_repo": false, "units_map": readUnits,
}, nil); err != nil {
return fmt.Errorf("the team could not be made read-only: %w", err)
}
s.say("made the team %s in %s read-only (it was %s)", full, org, team.Permission)
}
return s.sync(ctx, org, *team, repos, members)
}
// sync makes the team's repositories and members exactly the sets (nil is none).
func (s *Syncer) sync(ctx context.Context, org string, t forgeTeam, repos, members map[string]bool) error {
var errs []string
var haveRepos []forgeRepo
if err := s.list(ctx, fmt.Sprintf("/api/v1/teams/%d/repos", t.ID), &haveRepos); err != nil {
return err
}
held := map[string]bool{}
for _, r := range haveRepos {
held[r.FullName] = true
if !repos[r.FullName] {
if err := s.Forge.SendJSON(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/teams/%d/repos/%s", t.ID, r.FullName), nil, nil); err != nil {
errs = append(errs, err.Error())
continue
}
s.say("took %s from the team %s", r.FullName, t.Name)
}
}
for _, r := range sortedKeys(repos) {
if held[r] {
continue
}
if err := s.Forge.SendJSON(ctx, http.MethodPut, fmt.Sprintf("/api/v1/teams/%d/repos/%s", t.ID, r), nil, nil); err != nil {
errs = append(errs, fmt.Sprintf("%s could not be given to the team: %v", r, err))
continue
}
s.say("gave %s to the team %s, to read", r, t.Name)
}
var haveMembers []forgeMember
if err := s.list(ctx, fmt.Sprintf("/api/v1/teams/%d/members", t.ID), &haveMembers); err != nil {
return err
}
in := map[string]bool{}
for _, m := range haveMembers {
in[m.Login] = true
if !members[m.Login] {
if err := s.Forge.SendJSON(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/teams/%d/members/%s", t.ID, url.PathEscape(m.Login)), nil, nil); err != nil {
errs = append(errs, err.Error())
continue
}
s.say("took %s out of the team %s", m.Login, t.Name)
}
}
for _, m := range sortedKeys(members) {
if in[m] {
continue
}
if err := s.Forge.GetJSON(ctx, "/api/v1/users/"+url.PathEscape(m), nil); err != nil {
if errors.Is(err, forgehttp.ErrNotFound) {
s.say("skipped %s for the team %s: the account does not exist yet", m, t.Name)
continue
}
errs = append(errs, err.Error())
continue
}
if err := s.Forge.SendJSON(ctx, http.MethodPut, fmt.Sprintf("/api/v1/teams/%d/members/%s", t.ID, url.PathEscape(m)), nil, nil); err != nil {
errs = append(errs, fmt.Sprintf("%s could not be put in the team: %v", m, err))
continue
}
s.say("put %s in the team %s", m, t.Name)
}
if len(errs) > 0 {
return errors.New(strings.Join(errs, "; "))
}
return nil
}
func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// list reads every page of a list answer into into (a pointer to a slice).
func (s *Syncer) list(ctx context.Context, path string, into any) error {
var all []json.RawMessage
for p := 1; ; p++ {
var got []json.RawMessage
if err := s.Forge.GetJSON(ctx, fmt.Sprintf("%s?limit=%d&page=%d", path, page, p), &got); err != nil {
return err
}
all = append(all, got...)
if len(got) < page {
break
}
}
raw, _ := json.Marshal(all)
return json.Unmarshal(raw, into)
}
func (s *Syncer) teams(ctx context.Context, org string) ([]forgeTeam, error) {
var out []forgeTeam
if err := s.list(ctx, "/api/v1/orgs/"+org+"/teams", &out); err != nil {
if errors.Is(err, forgehttp.ErrNotFound) {
return nil, fmt.Errorf("the organisation %s does not exist on the forge", org)
}
return nil, err
}
return out, nil
}
// orgs are the organisations on the forge, where a read team no longer in the value is found.
func (s *Syncer) orgs(ctx context.Context) ([]string, error) {
var out []struct {
Name string `json:"name"`
}
if err := s.list(ctx, "/api/v1/admin/orgs", &out); err != nil {
return nil, fmt.Errorf("the forge's organisations could not be read: %w", err)
}
names := make([]string, len(out))
for i, o := range out {
names[i] = o.Name
}
return names, nil
}
+333
View File
@@ -0,0 +1,333 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"testing"
"gitea/internal/forgehttp"
)
type fTeam struct {
ID int64
Org string
Name string
Perm string
Units map[string]string
Repos map[string]bool
Members map[string]bool
}
type fakeForge struct {
mu sync.Mutex
orgs []string
users map[string]bool
teams []*fTeam
next int64
}
func newFake(orgs []string, users ...string) *fakeForge {
f := &fakeForge{orgs: orgs, users: map[string]bool{}, next: 1}
for _, u := range users {
f.users[u] = true
}
return f
}
func (f *fakeForge) add(org, name, perm string, repos, members []string) *fTeam {
t := &fTeam{ID: f.next, Org: org, Name: name, Perm: perm, Repos: map[string]bool{}, Members: map[string]bool{}}
f.next++
for _, r := range repos {
t.Repos[r] = true
}
for _, m := range members {
t.Members[m] = true
}
f.teams = append(f.teams, t)
return t
}
func (f *fakeForge) byName(org, name string) *fTeam {
for _, t := range f.teams {
if t.Org == org && t.Name == name {
return t
}
}
return nil
}
var (
orgTeams = regexp.MustCompile(`^/api/v1/orgs/([^/]+)/teams$`)
teamPath = regexp.MustCompile(`^/api/v1/teams/(\d+)$`)
teamRepos = regexp.MustCompile(`^/api/v1/teams/(\d+)/repos(?:/(.+))?$`)
teamMems = regexp.MustCompile(`^/api/v1/teams/(\d+)/members(?:/(.+))?$`)
)
func (f *fakeForge) find(id string) *fTeam {
n, _ := strconv.ParseInt(id, 10, 64)
for _, t := range f.teams {
if t.ID == n {
return t
}
}
return nil
}
func keys(m map[string]bool) []string {
out := []string{}
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func (f *fakeForge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if u, p, ok := r.BasicAuth(); !ok || u != "mesh-admin" || p != "pw" {
http.Error(w, "no", http.StatusUnauthorized)
return
}
reply := func(status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if v != nil {
_ = json.NewEncoder(w).Encode(v)
}
}
paged := r.URL.Query().Get("page") != "" && r.URL.Query().Get("page") != "1"
var in map[string]any
_ = json.NewDecoder(r.Body).Decode(&in)
p := r.URL.Path
switch {
case p == "/api/v1/version":
reply(200, map[string]string{"version": "1"})
case p == "/api/v1/admin/orgs":
out := []map[string]string{}
for _, o := range f.orgs {
out = append(out, map[string]string{"name": o})
}
if paged {
out = nil
}
reply(200, out)
case strings.HasPrefix(p, "/api/v1/users/"):
if !f.users[strings.TrimPrefix(p, "/api/v1/users/")] {
reply(404, nil)
return
}
reply(200, map[string]string{})
case orgTeams.MatchString(p):
org := orgTeams.FindStringSubmatch(p)[1]
known := false
for _, o := range f.orgs {
known = known || o == org
}
if !known {
reply(404, nil)
return
}
if r.Method == http.MethodPost {
units, _ := in["units_map"].(map[string]any)
t := f.add(org, in["name"].(string), in["permission"].(string), nil, nil)
t.Units = map[string]string{}
for k, v := range units {
t.Units[k] = v.(string)
}
reply(201, map[string]any{"id": t.ID, "name": t.Name, "permission": t.Perm})
return
}
out := []map[string]any{}
for _, t := range f.teams {
if t.Org == org && !paged {
out = append(out, map[string]any{"id": t.ID, "name": t.Name, "permission": t.Perm})
}
}
reply(200, out)
case teamPath.MatchString(p):
t := f.find(teamPath.FindStringSubmatch(p)[1])
t.Perm = in["permission"].(string)
reply(200, nil)
case teamRepos.MatchString(p):
m := teamRepos.FindStringSubmatch(p)
t := f.find(m[1])
switch {
case m[2] == "" && r.Method == http.MethodGet:
out := []map[string]string{}
for _, k := range keys(t.Repos) {
out = append(out, map[string]string{"full_name": k})
}
if paged {
out = nil
}
reply(200, out)
case r.Method == http.MethodPut:
t.Repos[m[2]] = true
reply(204, nil)
case r.Method == http.MethodDelete:
delete(t.Repos, m[2])
reply(204, nil)
}
case teamMems.MatchString(p):
m := teamMems.FindStringSubmatch(p)
t := f.find(m[1])
switch {
case m[2] == "" && r.Method == http.MethodGet:
out := []map[string]string{}
for _, k := range keys(t.Members) {
out = append(out, map[string]string{"login": k})
}
if paged {
out = nil
}
reply(200, out)
case r.Method == http.MethodPut:
t.Members[m[2]] = true
reply(204, nil)
case r.Method == http.MethodDelete:
delete(t.Members, m[2])
reply(204, nil)
}
default:
reply(404, nil)
}
}
func syncer(t *testing.T, f *fakeForge) (*Syncer, *[]string) {
t.Helper()
srv := httptest.NewServer(f)
t.Cleanup(srv.Close)
pw := filepath.Join(t.TempDir(), "pw")
if err := os.WriteFile(pw, []byte("pw\n"), 0o600); err != nil {
t.Fatal(err)
}
var said []string
return &Syncer{Forge: forgehttp.Client{URL: srv.URL, User: "mesh-admin", PasswordFile: pw},
Log: func(format string, a ...any) { said = append(said, sprintf(format, a...)) }}, &said
}
func sprintf(format string, a ...any) string { return fmt.Sprintf(format, a...) }
func equal(a, b []string) bool { return strings.Join(a, ",") == strings.Join(b, ",") }
// The first test of issue 758: a value makes a read-only team with exactly its repositories and members; a changed
// value takes a repository and a member away; a team that is not named agent-read-* is never touched.
func TestValueMakesReadTeamAndChangeRemoves(t *testing.T) {
f := newFake([]string{"novox"}, "agent-g14", "agent-ace")
other := f.add("novox", "Owners", "owner", []string{"novox/hq"}, []string{"agent-ace"})
s, said := syncer(t, f)
ctx := context.Background()
err := s.Sync(ctx, Value{"all": {Repos: []string{"novox/hq", "novox/mesh-catalog"}, Members: []string{"g14"}}})
if err != nil {
t.Fatal(err)
}
team := f.byName("novox", "agent-read-all")
if team == nil || team.Perm != "read" {
t.Fatalf("team = %+v, want a read team", team)
}
for _, u := range team.Units {
if u != "read" {
t.Errorf("unit %q, want read only", u)
}
}
if !equal(keys(team.Repos), []string{"novox/hq", "novox/mesh-catalog"}) || !equal(keys(team.Members), []string{"agent-g14"}) {
t.Fatalf("repos %v members %v", keys(team.Repos), keys(team.Members))
}
before := len(*said)
if err := s.Sync(ctx, Value{"all": {Repos: []string{"novox/hq", "novox/mesh-catalog"}, Members: []string{"g14"}}}); err != nil {
t.Fatal(err)
}
if len(*said) != before {
t.Errorf("a second run changed something: %v", (*said)[before:])
}
if err := s.Sync(ctx, Value{"all": {Repos: []string{"novox/hq"}, Members: nil}}); err != nil {
t.Fatal(err)
}
if !equal(keys(team.Repos), []string{"novox/hq"}) || len(team.Members) != 0 {
t.Fatalf("after change: repos %v members %v", keys(team.Repos), keys(team.Members))
}
// the team leaves the value: emptied, never deleted
if err := s.Sync(ctx, Value{}); err != nil {
t.Fatal(err)
}
if f.byName("novox", "agent-read-all") == nil || len(team.Repos) != 0 || len(team.Members) != 0 {
t.Fatalf("removed team not emptied: %+v", team)
}
if other.Perm != "owner" || !equal(keys(other.Repos), []string{"novox/hq"}) || !equal(keys(other.Members), []string{"agent-ace"}) {
t.Fatalf("a team without the prefix was touched: %+v", other)
}
for _, line := range *said {
if strings.Contains(line, "pw") {
t.Errorf("a line names the password: %q", line)
}
}
}
func TestPermissionIsCorrectedAndMissingAccountSkipped(t *testing.T) {
f := newFake([]string{"novox"}, "agent-g14")
team := f.add("novox", "agent-read-all", "write", nil, nil)
s, said := syncer(t, f)
err := s.Sync(context.Background(), Value{"all": {Repos: []string{"novox/hq"}, Members: []string{"g14", "later"}}})
if err != nil {
t.Fatal(err)
}
if team.Perm != "read" || !equal(keys(team.Members), []string{"agent-g14"}) {
t.Fatalf("team = %+v", team)
}
if !strings.Contains(strings.Join(*said, "\n"), "agent-later") {
t.Errorf("the missing account was not said: %v", *said)
}
}
func TestRefusesWhatIsNotAReadTeam(t *testing.T) {
f := newFake([]string{"novox", "other"})
s, _ := syncer(t, f)
for name, v := range map[string]Value{
"no repo": {"a": {Members: []string{"g14"}}},
"two owners": {"a": {Repos: []string{"novox/hq", "other/x"}}},
"bad repo": {"a": {Repos: []string{"hq"}}},
"bad member": {"a": {Repos: []string{"novox/hq"}, Members: []string{"../x"}}},
"unknown org": {"a": {Repos: []string{"nowhere/hq"}}},
"bad teamname": {"a b": {Repos: []string{"novox/hq"}}},
} {
if err := s.Sync(context.Background(), v); err == nil {
t.Errorf("%s: no error", name)
}
}
if len(f.teams) != 0 {
t.Errorf("teams made: %+v", f.teams)
}
}
func TestReadValue(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "t.json")
_ = os.WriteFile(p, []byte(`{"agent-read-teams":{"a":{"repos":["o/r"],"members":["g14"]}}}`), 0o600)
v, err := ReadValue(p)
if err != nil || len(v["a"].Repos) != 1 || v["a"].Members[0] != "g14" {
t.Fatalf("%v %v", v, err)
}
_ = os.WriteFile(p, []byte(`{}`), 0o600)
if v, err := ReadValue(p); err != nil || len(v) != 0 {
t.Fatalf("%v %v", v, err)
}
_ = os.WriteFile(p, []byte(`{"agent-read-teams":{"a":{"repos":["o/r"],"write":true}}}`), 0o600)
if _, err := ReadValue(p); err == nil {
t.Fatal("an unknown field was accepted")
}
}
+72
View File
@@ -0,0 +1,72 @@
// 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.
//
// 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
// 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.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// ToolNames are the tools this bundle serves. The module's manifest lists no `tools` (see the npm-registry bundle
// for why); the README names this one, and a test holds the three together.
var ToolNames = []string{ToolName}
func main() {
if err := stdio.Serve("", Tools(MinterFromEnv)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// Tools are the bundle's tools over the minter the function gives, asked per call, so a manifest that does not set
// the environment is a refusal naming what is missing, not a bundle that will not start.
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 " +
"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.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"provision": map[string]any{"type": "string", "description": "the provision, " + Provision},
"seal_to": map[string]any{"type": "string", "description": "the key made for this call, base64"},
"holders": map[string]any{
"type": "array",
"description": "the provision's holders: each a consuming module on one machine",
"items": map[string]any{"type": "object", "properties": map[string]any{
"node": map[string]any{"type": "string"},
"module": map[string]any{"type": "string"},
}},
},
},
"required": []string{"provision", "seal_to", "holders"},
},
Run: func(args map[string]any) (any, error) {
caller := stdio.CallerOf(args)
if err := fromTheVault(caller); err != nil {
return nil, err
}
ask, err := readAsk(args)
if err != nil {
return nil, err
}
m, err := minter()
if err != nil {
return nil, err
}
return m.Answer(ask)
},
}}
}
+348
View File
@@ -0,0 +1,348 @@
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"regexp"
"sort"
"strings"
"sync"
"time"
"golang.org/x/crypto/nacl/box"
"gitea/internal/forgehttp"
)
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 = "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.
Holder = "claude-code"
// 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-"
// 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.
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"}
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
// tool would hold every agent's credential.
func fromTheVault(caller string) error {
node, module, ok := strings.Cut(caller, ".")
if ok && module == vaultModule && nodeName.MatchString(node) {
return nil
}
return fmt.Errorf("%s answers the vault alone (novox/hq ADR 0349 clause 10, ADR 0365); %q is not the vault, and "+
"nothing was minted", ToolName, caller)
}
// HolderAsk is one holder the vault named.
type HolderAsk struct {
Node string `json:"node"`
Module string `json:"module"`
}
// Ask is one call: the holders, and the key to seal each answer to.
type Ask struct {
SealTo [32]byte
Holders []HolderAsk
}
// readAsk reads the call's arguments, refusing anything that is not the vault's per-holder ask.
func readAsk(args map[string]any) (Ask, error) {
if p, _ := args["provision"].(string); p != Provision {
return Ask{}, fmt.Errorf("%s issues %s alone, not %q; nothing was minted", ToolName, Provision, p)
}
key, _ := args["seal_to"].(string)
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(key))
if err != nil || len(raw) != 32 {
return Ask{}, errors.New("seal_to is not the key of a call; nothing was minted")
}
var out Ask
copy(out.SealTo[:], raw)
list, ok := args["holders"].([]any)
if !ok || len(list) == 0 {
return Ask{}, fmt.Errorf("%s answers a value for each holder, and none was named; nothing was minted", ToolName)
}
seen := map[HolderAsk]bool{}
for _, item := range list {
m, _ := item.(map[string]any)
node, _ := m["node"].(string)
module, _ := m["module"].(string)
h := HolderAsk{Node: strings.TrimSpace(node), Module: strings.TrimSpace(module)}
if h.Node == "" || h.Module == "" || seen[h] {
return Ask{}, errors.New("each holder is a node and a module, named once; nothing was minted")
}
seen[h] = true
out.Holders = append(out.Holders, h)
}
return out, nil
}
// Minter mints the agents' tokens with the administrator's login, which sets the forge up and nothing else (ADR
// 0365 §2).
type Minter struct {
Forge forgehttp.Client
// Node is the forge's own machine: a holder there also reaches the forge at Forge.URL.
Node string
// OriginsFile holds the forge's public origins, one per line (https://<its route's name>): the origins every
// holder's credential is for.
OriginsFile string
// Password makes the password an agent's account is created with: random, sent once and kept nowhere.
Password func() string
Log func(format string, a ...any)
}
// MinterFromEnv is the minter as the manifest hands it to this bundle. It builds the administrator's client itself:
// the module's words reach all its bundles, and a word naming another bundle's token must not make this one act as
// another account.
func MinterFromEnv() (*Minter, error) {
m := &Minter{
Forge: forgehttp.Client{URL: strings.TrimRight(os.Getenv("MESH_GITEA_URL"), "/"),
User: os.Getenv("MESH_GITEA_ADMIN_USER"), PasswordFile: os.Getenv("MESH_GITEA_ADMIN_PASSWORD_FILE"),
Timeout: 20 * time.Second},
Node: os.Getenv("MESH_NODE"),
OriginsFile: os.Getenv("MESH_GITEA_ORIGINS_FILE"),
}
var missing []string
for _, kv := range [][2]string{{"MESH_GITEA_URL", m.Forge.URL}, {"MESH_GITEA_ADMIN_USER", m.Forge.User},
{"MESH_GITEA_ADMIN_PASSWORD_FILE", m.Forge.PasswordFile}, {"MESH_GITEA_ORIGINS_FILE", m.OriginsFile}} {
if kv[1] == "" {
missing = append(missing, kv[0])
}
}
if len(missing) > 0 {
return nil, fmt.Errorf("the forge cannot be reached: %s not set by the manifest; nothing was minted", strings.Join(missing, ", "))
}
return m, nil
}
// Sealed is one holder's credential as the vault takes it.
type Sealed struct {
Node string `json:"node"`
Module string `json:"module"`
Sealed string `json:"sealed"`
}
// Answer is what the tool answers: a sealed credential for each holder answered, and why each other was not. Never
// a token.
type Answer struct {
Holders []Sealed `json:"holders"`
Skipped map[string]string `json:"skipped,omitempty"`
}
// one call at a time: a mint deletes the account's token from before, and two at once would delete each other's.
var minting sync.Mutex
// Answer mints each holder's token and seals its credential lines. A holder that is not an agent's module is not
// answered; a failure for one holder fails the call, so the vault keeps nothing rather than a part.
func (m *Minter) Answer(ask Ask) (Answer, error) {
minting.Lock()
defer minting.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), deadline)
defer cancel()
origins, err := m.origins()
if err != nil {
return Answer{}, err
}
out := Answer{Holders: []Sealed{}}
skip := func(h HolderAsk, why string) {
if out.Skipped == nil {
out.Skipped = map[string]string{}
}
out.Skipped[h.Module+" on "+h.Node] = why
}
for _, h := range ask.Holders {
if h.Module != Holder {
skip(h, fmt.Sprintf("only %s places the forge's read credential, in its agent's home (ADR 0365 §4)", Holder))
continue
}
if !nodeName.MatchString(h.Node) {
skip(h, "not a machine's name an account can be named for")
continue
}
reach := origins
if h.Node == m.Node {
reach = append(append([]string{}, origins...), m.Forge.URL)
}
if len(reach) == 0 {
skip(h, "the forge names no origin it is reached at from that machine")
continue
}
account := AccountPrefix + h.Node
if err := m.ensureAccount(ctx, account); err != nil {
return Answer{}, err
}
token, err := m.mint(ctx, account)
if err != nil {
return Answer{}, err
}
lines, err := credentialLines(account, token, reach)
token = ""
if err != nil {
return Answer{}, err
}
sealed, err := box.SealAnonymous(nil, []byte(lines), &ask.SealTo, rand.Reader)
lines = ""
if err != nil {
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)
}
return out, nil
}
// origins are the forge's public origins, as its route names them.
func (m *Minter) origins() ([]string, error) {
raw, err := os.ReadFile(m.OriginsFile)
if err != nil {
return nil, fmt.Errorf("the forge's origins cannot be read (%s); nothing was minted: %w", m.OriginsFile, err)
}
var out []string
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimRight(strings.TrimSpace(line), "/")
if line == "" {
continue
}
u, err := url.Parse(line)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil || u.Path != "" {
return nil, fmt.Errorf("%s names %q, which is not an origin (scheme://host); nothing was minted", m.OriginsFile, line)
}
out = append(out, line)
}
return out, nil
}
// credentialLines are the lines of git's credential store for one account at each origin: what the agent's module
// places, and git reads by protocol and host.
func credentialLines(account, token string, origins []string) (string, error) {
seen := map[string]bool{}
var lines []string
for _, o := range origins {
u, err := url.Parse(strings.TrimRight(o, "/"))
if err != nil || u.Host == "" {
return "", fmt.Errorf("%q is not an origin", o)
}
u.Path, u.RawQuery, u.Fragment = "", "", ""
u.User = url.UserPassword(account, token)
if line := u.String(); !seen[line] {
seen[line] = true
lines = append(lines, line)
}
}
sort.Strings(lines)
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. It adds it to no team: what it may read beyond public
// repositories is the operator's to grant (ADR 0365 §3).
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", 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
}
type forgeToken struct {
ID int64 `json:"id"`
Name string `json:"name"`
SHA1 string `json:"sha1"`
}
// 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.
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 {
return "", fmt.Errorf("the tokens of %s could not be read: %w", account, err)
}
for _, t := range held {
if t.Name != TokenName {
continue
}
if err := m.Forge.SendJSON(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/users/%s/tokens/%d", account, t.ID), nil, nil); err != nil &&
!errors.Is(err, forgehttp.ErrNotFound) {
return "", fmt.Errorf("the token of %s from before could not be deleted: %w", account, err)
}
}
var made forgeToken
if err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/users/"+account+"/tokens",
map[string]any{"name": TokenName, "scopes": TokenScopes}, &made); err != nil {
return "", fmt.Errorf("no token could be minted for %s: %w", account, err)
}
if strings.TrimSpace(made.SHA1) == "" {
return "", fmt.Errorf("the forge answered no token for %s", account)
}
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)
}
func (m *Minter) say(format string, a ...any) {
if m.Log != nil {
m.Log(format, a...)
return
}
fmt.Fprintf(os.Stderr, format+"\n", a...)
}
+376
View File
@@ -0,0 +1,376 @@
package main
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"sync"
"testing"
"golang.org/x/crypto/nacl/box"
"gitea/internal/forgehttp"
)
const adminPassword = "admin-password-never-said"
// fakeForge is gitea's API as far as this bundle uses it, as the administrator's login reaches it.
type fakeForge struct {
mu sync.Mutex
users map[string]*forgeUser
tokens map[string][]forgeToken
next int64
calls []string
made map[string]map[string]any
minted map[string][]string // account -> scopes of its last mint
failMint bool
}
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}
}
var tokenPath = regexp.MustCompile(`^/api/v1/users/([^/]+)/tokens(?:/(\d+))?$`)
func (f *fakeForge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
user, pass, ok := r.BasicAuth()
if !ok || user != "mesh-admin" || pass != adminPassword {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
f.calls = append(f.calls, r.Method+" "+r.URL.Path)
body, _ := io.ReadAll(r.Body)
var in map[string]any
_ = json.Unmarshal(body, &in)
reply := func(status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if v != nil {
_ = json.NewEncoder(w).Encode(v)
}
}
switch {
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/users/") && strings.Count(r.URL.Path, "/") == 4:
u, ok := f.users[strings.TrimPrefix(r.URL.Path, "/api/v1/users/")]
if !ok {
reply(404, map[string]any{"message": "user does not exist"})
return
}
reply(200, map[string]any{"is_admin": u.IsAdmin, "active": u.Active, "prohibit_login": u.ProhibitLogin})
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/users":
name, _ := in["username"].(string)
f.users[name] = &forgeUser{Active: true}
f.made[name] = in
reply(201, map[string]any{"login": name})
case r.Method == http.MethodPatch && strings.HasPrefix(r.URL.Path, "/api/v1/admin/users/"):
u := f.users[strings.TrimPrefix(r.URL.Path, "/api/v1/admin/users/")]
u.IsAdmin, _ = in["admin"].(bool)
u.Active, _ = in["active"].(bool)
u.ProhibitLogin, _ = in["prohibit_login"].(bool)
reply(200, map[string]any{})
case tokenPath.MatchString(r.URL.Path):
m := tokenPath.FindStringSubmatch(r.URL.Path)
account := m[1]
switch r.Method {
case http.MethodGet:
reply(200, f.tokens[account])
case http.MethodDelete:
kept := f.tokens[account][:0]
for _, t := range f.tokens[account] {
if fmt.Sprint(t.ID) != m[2] {
kept = append(kept, t)
}
}
f.tokens[account] = kept
reply(204, nil)
case http.MethodPost:
if f.failMint {
reply(500, map[string]any{"message": "the forge is unwell"})
return
}
name, _ := in["name"].(string)
for _, t := range f.tokens[account] {
if t.Name == name {
reply(400, map[string]any{"message": "access token name has been used already"})
return
}
}
var scopes []string
for _, s := range in["scopes"].([]any) {
scopes = append(scopes, s.(string))
}
f.minted[account] = scopes
b := make([]byte, 20)
_, _ = rand.Read(b)
t := forgeToken{ID: f.next, Name: name, SHA1: fmt.Sprintf("%x", b)}
f.next++
f.tokens[account] = append(f.tokens[account], forgeToken{ID: t.ID, Name: t.Name})
reply(201, t)
}
default:
reply(404, map[string]any{"message": "not here: " + r.URL.Path})
}
}
type harness struct {
forge *fakeForge
server *httptest.Server
minter *Minter
public *[32]byte
private *[32]byte
logged *[]string
}
func newHarness(t *testing.T) harness {
t.Helper()
f := newFakeForge()
srv := httptest.NewServer(f)
t.Cleanup(srv.Close)
dir := t.TempDir()
pw := filepath.Join(dir, "admin.secret")
origins := filepath.Join(dir, "origins")
if err := os.WriteFile(pw, []byte(adminPassword+"\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(origins, []byte("https://git.example.test\n"), 0o644); err != nil {
t.Fatal(err)
}
pub, priv, err := box.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
var logged []string
m := &Minter{Forge: forgehttp.Client{URL: srv.URL, User: "mesh-admin", PasswordFile: pw}, Node: "anchor",
OriginsFile: origins, Log: func(format string, a ...any) { logged = append(logged, fmt.Sprintf(format, a...)) }}
return harness{forge: f, server: srv, minter: m, public: pub, private: priv, logged: &logged}
}
func (h harness) open(t *testing.T, sealed string) string {
t.Helper()
raw, err := base64.StdEncoding.DecodeString(sealed)
if err != nil {
t.Fatal(err)
}
out, ok := box.OpenAnonymous(nil, raw, h.public, h.private)
if !ok {
t.Fatal("a holder's value is not sealed to the call's key")
}
return string(out)
}
func (h harness) args(holders ...HolderAsk) map[string]any {
list := []any{}
for _, x := range holders {
list = append(list, map[string]any{"node": x.Node, "module": x.Module})
}
return map[string]any{"provision": Provision, "seal_to": base64.StdEncoding.EncodeToString(h.public[:]), "holders": list}
}
func TestOnlyTheVaultIsAnsweredAndNothingIsMintedForAnyoneElse(t *testing.T) {
h := newHarness(t)
for _, caller := range []string{"controller", "novox.node-tools", "provisioner.anchor", "anchor.claude-code",
"mesh-vault", "anchor.mesh-vault.x", "", "unproven"} {
if err := fromTheVault(caller); err == nil {
t.Errorf("%q was taken for the vault", caller)
}
}
if err := fromTheVault("novox.mesh-vault"); err != nil {
t.Errorf("the vault was refused: %v", err)
}
// Through the tool as the runtime serves it: a call no runtime named a caller for is the vault's to nobody.
tools := Tools(func() (*Minter, error) { return h.minter, nil })
if _, err := tools[0].Run(h.args(HolderAsk{"anchor", "claude-code"})); err == nil || !strings.Contains(err.Error(), "vault alone") {
t.Fatalf("an unproven caller was answered: %v", err)
}
if len(h.forge.calls) != 0 {
t.Fatalf("the forge was reached for a caller that is not the vault: %v", h.forge.calls)
}
}
func TestEachAgentGetsAnAccountOfItsOwnAndAReadTokenSealedForItAlone(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 {
t.Fatal(err)
}
got, err := h.minter.Answer(ask)
if err != nil {
t.Fatal(err)
}
if len(got.Holders) != 2 {
t.Fatalf("answered %d holders, want the two agents: %+v", len(got.Holders), got)
}
if !strings.Contains(got.Skipped["lab on laptop"], Holder) {
t.Errorf("a holder that is not an agent's module was not said: %v", got.Skipped)
}
lines := map[string]string{}
for _, s := range got.Holders {
lines[s.Node] = h.open(t, s.Sealed)
}
anchorTok := tokenIn(t, lines["anchor"], "agent-anchor")
laptopTok := tokenIn(t, lines["laptop"], "agent-laptop")
if anchorTok == laptopTok {
t.Fatal("two agents were handed one token")
}
// The forge's own machine reaches it at its own address too; another machine only at its public origin.
if !strings.Contains(lines["anchor"], "https://agent-anchor:"+anchorTok+"@git.example.test\n") ||
!strings.Contains(lines["anchor"], strings.Replace(h.server.URL, "http://", "http://agent-anchor:"+anchorTok+"@", 1)) {
t.Errorf("anchor's credential lines: %q", strings.ReplaceAll(lines["anchor"], anchorTok, "<token>"))
}
if lines["laptop"] != "https://agent-laptop:"+laptopTok+"@git.example.test\n" {
t.Errorf("laptop's credential lines: %q", strings.ReplaceAll(lines["laptop"], laptopTok, "<token>"))
}
for _, account := range []string{"agent-anchor", "agent-laptop"} {
u := h.forge.users[account]
if u == nil || u.IsAdmin {
t.Errorf("%s: %+v, want a plain account", account, u)
}
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])
}
}
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 _, made := h.forge.users["agent-laptop-lab"]; made || len(h.forge.users) != 2 {
t.Errorf("accounts made: %v", h.forge.users)
}
said := strings.Join(*h.logged, "\n")
if strings.Contains(said, anchorTok) || strings.Contains(said, laptopTok) {
t.Fatal("a token was said in the log")
}
}
func TestAskedAgainTheTokenIsMintedAnewAndTheOneFromBeforeDeleted(t *testing.T) {
h := newHarness(t)
ask, _ := readAsk(h.args(HolderAsk{"laptop", "claude-code"}))
first, err := h.minter.Answer(ask)
if err != nil {
t.Fatal(err)
}
h.forge.tokens["agent-laptop"] = append(h.forge.tokens["agent-laptop"], forgeToken{ID: 99, Name: "a-persons-own"})
again, err := h.minter.Answer(ask)
if err != nil {
t.Fatal(err)
}
if h.open(t, first.Holders[0].Sealed) == h.open(t, again.Holders[0].Sealed) {
t.Fatal("asked again, the same token was answered: it was not rotated")
}
var names []string
for _, tok := range h.forge.tokens["agent-laptop"] {
names = append(names, tok.Name)
}
slices.Sort(names)
if !slices.Equal(names, []string{"a-persons-own", TokenName}) {
t.Fatalf("the account holds %v: the token from before is not deleted, or another was", names)
}
}
func TestAnAccountMadeAnAdministratorByHandIsMadePlainAgain(t *testing.T) {
h := newHarness(t)
h.forge.users["agent-laptop"] = &forgeUser{IsAdmin: true, Active: true}
ask, _ := readAsk(h.args(HolderAsk{"laptop", "claude-code"}))
if _, err := h.minter.Answer(ask); err != nil {
t.Fatal(err)
}
if h.forge.users["agent-laptop"].IsAdmin {
t.Fatal("the agent's account is still an administrator")
}
}
func TestAFailureSaysNeitherTheAdminsPasswordNorATokenAndAnswersNothing(t *testing.T) {
h := newHarness(t)
h.forge.failMint = true
ask, _ := readAsk(h.args(HolderAsk{"anchor", "claude-code"}, HolderAsk{"laptop", "claude-code"}))
got, err := h.minter.Answer(ask)
if err == nil || len(got.Holders) != 0 {
t.Fatalf("a failed mint answered %+v, %v", got, err)
}
if strings.Contains(err.Error(), adminPassword) {
t.Fatal("the admin's password was said in an error")
}
// A wrong password is a refusal, said without it.
if err := os.WriteFile(h.minter.Forge.PasswordFile, []byte("wrong"), 0o600); err != nil {
t.Fatal(err)
}
h.forge.failMint = false
if _, err := h.minter.Answer(ask); err == nil || strings.Contains(err.Error(), "wrong") {
t.Fatalf("a refused login: %v", err)
}
}
func TestTheAskIsReadStrictly(t *testing.T) {
h := newHarness(t)
for name, args := range map[string]map[string]any{
"another provision": {"provision": "git", "seal_to": base64.StdEncoding.EncodeToString(h.public[:]),
"holders": []any{map[string]any{"node": "a", "module": "claude-code"}}},
"no key": {"provision": Provision, "holders": []any{map[string]any{"node": "a", "module": "claude-code"}}},
"short key": {"provision": Provision, "seal_to": "AAAA", "holders": []any{map[string]any{"node": "a", "module": "claude-code"}}},
"no holders": {"provision": Provision, "seal_to": base64.StdEncoding.EncodeToString(h.public[:])},
"a holder twice": {"provision": Provision, "seal_to": base64.StdEncoding.EncodeToString(h.public[:]),
"holders": []any{map[string]any{"node": "a", "module": "claude-code"}, map[string]any{"node": "a", "module": "claude-code"}}},
} {
if _, err := readAsk(args); err == nil {
t.Errorf("%s was taken", name)
}
}
}
func TestTheReadmeNamesWhatTheBundleServes(t *testing.T) {
var served []string
for _, tool := range Tools(MinterFromEnv) {
served = append(served, tool.Name)
}
if !slices.Equal(served, ToolNames) {
t.Fatalf("served %v, named %v", served, ToolNames)
}
readme, err := os.ReadFile("../../README.md")
if err != nil {
t.Fatal(err)
}
for _, n := range ToolNames {
if !strings.Contains(string(readme), "`"+n+"`") {
t.Errorf("the README does not name %s", n)
}
}
manifest, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(manifest), `"tool": "`+ToolName+`"`) || !strings.Contains(string(manifest), `"name": "`+Provision+`"`) {
t.Error("the manifest does not offer the provision with this tool as its issuer")
}
}
// tokenIn is the token of account's credential lines, all one.
func tokenIn(t *testing.T, lines, account string) string {
t.Helper()
re := regexp.MustCompile(regexp.QuoteMeta(account) + `:([0-9a-f]{40})@`)
all := re.FindAllStringSubmatch(lines, -1)
if len(all) == 0 {
t.Fatalf("no credential for %s in the lines", account)
}
for _, m := range all {
if m[1] != all[0][1] {
t.Fatalf("%s's lines carry two tokens", account)
}
}
return all[0][1]
}
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// The module's directory, where its manifest and its files/ are.
const moduleDir = "../.."
// templateText is the text a file resource places on the machine: its template's, in the module's files/
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
func templateText(t *testing.T, r map[string]any) string {
t.Helper()
name, _ := r["template"].(string)
if name == "" {
t.Fatalf("resource %v names no template", r["id"])
}
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
if err != nil {
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
}
return string(raw)
}
// No file resource carries its text inline: each names its template in files/, and the build packs files/
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m struct {
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, r := range m.Resources {
if _, inline := r["content"]; inline {
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
}
if r["type"] == "file" {
templateText(t, r)
}
}
packs := 0
for _, a := range m.Build.Artifacts {
if a["kind"] == "archive" && a["from"] == "files" {
packs++
if a["name"] != "files" {
t.Errorf("the templates archive is named %v, not files", a["name"])
}
}
}
if packs != 1 {
t.Errorf("the build packs files/ into %d archives, not one", packs)
}
}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
https://${bound:route:name-web}
+3
View File
@@ -0,0 +1,3 @@
# The accounts whose "ready at <sha>" comment marks a pull request ready (novox/hq issue 722), from the setting
# ready-authors, written by the mesh. Replaced on every push; change the setting, never this file.
authors=${config:ready-authors}
+7
View File
@@ -0,0 +1,7 @@
GITEA__security__INTERNAL_TOKEN=${secret:internal-token}
GITEA__database__DB_TYPE=postgres
GITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port}
GITEA__database__NAME=${bound:postgres-database:as}
GITEA__database__USER=${bound:postgres-database:as}
GITEA__database__PASSWD=${secret:postgres-database}
GITEA__webhook__ALLOWED_HOST_LIST=172.17.0.1
+7 -2
View File
@@ -1,5 +1,10 @@
module gitea
go 1.22
go 1.25.0
require git.novox.be/novox/mesh-sdk/go v0.1.7
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009
require (
golang.org/x/crypto v0.50.0
golang.org/x/sys v0.43.0 // indirect
)
+6 -2
View File
@@ -1,2 +1,6 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 h1:I9rm8HhYRJ0+mzQQz+VAH0kgv2YJWkvctEc67Yd0PX8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+102 -8
View File
@@ -1,6 +1,7 @@
{
"module": "gitea",
"version": "1",
"moved": true,
"requires": [
"postgres-database",
"route",
@@ -156,11 +157,24 @@
}
}
},
"settings": {
"config": {
"url": {
"type": "string",
"description": "The address the module's tools reach the forge at, as a URL; unset, the code falls back to its environment, else this machine's gitea on its port.",
"trusted": true,
"default": ""
},
"agent-read-teams": {
"type": "object",
"description": "The read teams of the agents' forge accounts (ADR 0365 clause 3): each team by name, {\"<team>\": {\"repos\": [\"<owner>/<repo>\", ...], \"members\": [\"<node>\", ...]}}. The forge gets a team agent-read-<team> that reads only, with exactly those repositories and the accounts agent-<node>; a team taken out of the value is emptied. Nothing else on the forge is touched; an agent account is in no team until it is listed here.",
"trusted": true,
"default": {}
},
"ready-authors": {
"kind": "preference",
"default": "mesh-tools mesh-admin jschoubben",
"why": "the forge accounts whose comment \"ready at <full head sha>\" marks a pull request ready for mesh-delivery to merge, separated by spaces: the account the forge's tools act as (novox/hq ADR 0365), the admin account for marks made before the tools acted as their own, and the operator's (novox/hq issue 722). A comment by any other account is ignored and said in the forge's log. Trusted: who may have a change merged is the operator's word"
"type": "string",
"description": "the forge accounts whose comment \"ready at <full head sha>\" marks a pull request ready for mesh-delivery to merge, separated by spaces: the account the forge's tools act as (novox/hq ADR 0365), the admin account for marks made before the tools acted as their own, and the operator's (novox/hq issue 722). A comment by any other account is ignored and said in the forge's log. Trusted: who may have a change merged is the operator's word",
"trusted": true,
"default": "mesh-tools mesh-admin jschoubben"
}
},
"resources": [
@@ -182,7 +196,7 @@
"path": "${dir:mesh-state}/ready-authors",
"mode": "0600",
"trusted": true,
"content": "# The accounts whose \"ready at <sha>\" comment marks a pull request ready (novox/hq issue 722), from the setting\n# ready-authors, written by the mesh. Replaced on every push; change the setting, never this file.\nauthors=${setting:ready-authors}\n"
"template": "files/ready-authors"
},
{
"id": "forge-patches",
@@ -207,7 +221,7 @@
"type": "file",
"path": "${dir:state}/server.env",
"mode": "0600",
"content": "GITEA__security__INTERNAL_TOKEN=${secret:internal-token}\nGITEA__database__DB_TYPE=postgres\nGITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nGITEA__database__NAME=${bound:postgres-database:as}\nGITEA__database__USER=${bound:postgres-database:as}\nGITEA__database__PASSWD=${secret:postgres-database}\nGITEA__webhook__ALLOWED_HOST_LIST=172.17.0.1\n"
"template": "files/server.env"
},
{
"id": "data",
@@ -270,14 +284,53 @@
],
"secrets-in-environment": "gitea honours GITEA__database__PASSWD__FILE and GITEA__security__INTERNAL_TOKEN__FILE; convertible, awaiting a bed that proves it"
},
{
"id": "origins",
"type": "file",
"path": "${dir:mesh-state}/origins",
"mode": "0644",
"template": "files/origins"
},
{
"id": "runtime-config",
"type": "file",
"path": "${dir:mesh-state}/config.json",
"mode": "0600",
"content": "{}\n",
"template": "files/config.json",
"merge": "json",
"takes-settings": []
"takes-config": [
"url"
]
},
{
"id": "agent-teams-file",
"type": "file",
"path": "${dir:mesh-state}/agent-teams.json",
"mode": "0600",
"template": "files/agent-teams.json",
"merge": "json",
"takes-config": [
"agent-read-teams"
]
},
{
"id": "agent-teams",
"type": "process",
"name": "gitea-agent-teams",
"artifact": "agent-teams",
"run": [
"./agent-teams"
],
"run-once": true,
"env": {
"MESH_GITEA_URL": "http://127.0.0.1:${port:3000}",
"MESH_GITEA_ADMIN_USER": "mesh-admin",
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret",
"MESH_AGENT_TEAMS_FILE": "${dir:mesh-state}/agent-teams.json"
},
"restart-on": [
"agent-teams-file"
]
}
],
"provides": [
@@ -292,6 +345,14 @@
{
"name": "git",
"scope": "mesh"
},
{
"name": "gitea-read",
"scope": "mesh",
"credential": {
"issued-by": "software",
"tool": "gitea_agent_token"
}
}
],
"build": {
@@ -357,6 +418,39 @@
"MESH_GITEA_TOKEN_FILE": "${dir:runtime-state}/token",
"MESH_GITEA_PATCH_DIR": "${dir:forge-patches}"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
},
{
"name": "agent-token",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/agent-token",
"binary": "agent-token",
"loads": [
"agent-token"
],
"env": {
"MESH_GITEA_URL": "http://127.0.0.1:${port:3000}",
"MESH_GITEA_ADMIN_USER": "mesh-admin",
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret",
"MESH_GITEA_ORIGINS_FILE": "${dir:mesh-state}/origins"
}
},
{
"name": "agent-teams",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/agent-teams",
"binary": "agent-teams",
"loads": [
"agent-teams"
]
}
]
},
+1 -1
View File
@@ -262,7 +262,7 @@ test("the manifest hands the tools their account, and the merge-ready authors na
const manifest = JSON.parse(await readFile(new URL("../module.json", import.meta.url), "utf8"));
const code = manifest.build.artifacts.find((a: any) => a.name === "code");
assert.equal(code.env.MESH_GITEA_TOOLS_USER, TOOLS_ACCOUNT);
const authors = String(manifest.settings["ready-authors"].default).split(/\s+/);
const authors = String(manifest.config["ready-authors"].default).split(/\s+/);
assert.ok(authors.includes(TOOLS_ACCOUNT));
// Kept for now (review of issue 469): a pull request the admin account marked ready before the tools acted as
// their own keeps counting as ready after the delivery.