Feature 57 (config and provisioning): modules declare config and provisions, their files rendered from templates; synced with main (issues of feature 57, 874)

This commit is contained in:
2026-10-16 07:37:43 +00:00
parent 1a2c917abf
commit 9652b2d5ce
771 changed files with 52063 additions and 1774 deletions
+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]
}
+120
View File
@@ -0,0 +1,120 @@
package main
// The commit and the reads act as the tools' own account (novox/hq ADR 0365 §2, issue 469): the token the module
// keeps for `mesh-tools`, never the admin login. That account is no repository's administrator, so the forge
// refuses it the protection rules (403, as gitea's reqAdmin does); the commit then judges an existing branch by the
// protection the forge says it applies, and leaves a branch not made yet to the forge's own judgement.
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"gitea/internal/forgehttp"
)
const toolsToken = "tools-token-never-shown"
// asTools serves the fake forge to a bundle holding the tools' token: only that token is taken, the rules are
// refused as to a non-administrator, and the branches named protected say so as the forge does.
func asTools(t *testing.T, f *fakeForge, protected map[string]string, scopes string) (*Forge, *[]string) {
dir := t.TempDir()
file := filepath.Join(dir, "token")
if err := os.WriteFile(file, []byte(toolsToken+"\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(file+".scopes", []byte(scopes+"\n"), 0o600); err != nil {
t.Fatal(err)
}
var seen []string
const repo = "/api/v1/repos/acme/app"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = append(seen, r.Method+" "+r.URL.Path)
if _, _, basic := r.BasicAuth(); basic || r.Header.Get("Authorization") != "token "+toolsToken {
http.Error(w, "unauthorized", 401)
return
}
if r.URL.Path == repo+"/branch_protections" {
http.Error(w, `{"message":"token does not have the administration of this repository"}`, 403)
return
}
if name := strings.TrimPrefix(r.URL.Path, repo+"/branches/"); r.Method == "GET" && name != r.URL.Path {
if rule, ok := protected[name]; ok {
_, _ = w.Write([]byte(`{"name":"` + name + `","commit":{"id":"c0"},"protected":true,"effective_branch_protection_name":"` + rule + `"}`))
return
}
}
r.SetBasicAuth("mesh-admin", f.password) // the fake's own door, past the token checked above
f.handle(w, r)
}))
t.Cleanup(srv.Close)
return &Forge{Client: forgehttp.Client{URL: srv.URL, TokenFile: file, Account: "mesh-tools"}}, &seen
}
func TestTheToolsAccountCommitsOnANewBranchWithItsTokenThoughTheRulesAreNotItsToRead(t *testing.T) {
f := newFake(t)
forge, _ := asTools(t, f, nil, "read:user,write:issue,write:repository for mesh-tools")
got, err := commit(context.Background(), forge, ask(t, map[string]any{"branch": "fix/365",
"files": filesArg(map[string]any{"path": "x", "content": "x"})}))
if err != nil {
t.Fatal(err)
}
if len(f.writes) != 1 || !got.Created {
t.Fatalf("%d writes, %+v", len(f.writes), got)
}
}
func TestTheToolsAccountIsRefusedAProtectedBranchTheForgeSaysItGuardsAndTheDefaultBranch(t *testing.T) {
for branch, rule := range map[string]string{"release/1.0": "release/*", "fix/1-old": "fix/1-old", "main": ""} {
f := newFake(t)
protected := map[string]string{}
if rule != "" {
protected[branch] = rule
}
forge, _ := asTools(t, f, protected, "read:user,write:issue,write:repository for mesh-tools")
_, err := commit(context.Background(), forge, ask(t, map[string]any{"branch": branch,
"files": filesArg(map[string]any{"path": "x", "content": "x"})}))
if err == nil || !strings.Contains(err.Error(), "refused") || !strings.Contains(err.Error(), "ADR 0266") {
t.Errorf("%s: %v", branch, err)
}
if rule != "" && !strings.Contains(err.Error(), rule) {
t.Errorf("%s: the rule %s is not named: %v", branch, rule, err)
}
if len(f.writes)+len(f.patches) != 0 {
t.Errorf("%s: something was written", branch)
}
}
}
func TestATokenKeptForTheAdministratorIsNeverSent(t *testing.T) {
f := newFake(t)
// What the module kept before ADR 0365: the admin's token, its scopes line naming no account.
forge, seen := asTools(t, f, nil, "read:user,write:issue,write:repository")
_, err := commit(context.Background(), forge, ask(t, map[string]any{"branch": "fix/365",
"files": filesArg(map[string]any{"path": "x", "content": "x"})}))
if err == nil || !strings.Contains(err.Error(), "not mesh-tools's") {
t.Fatalf("%v", err)
}
if len(*seen) != 0 {
t.Fatalf("the forge was asked %v", *seen)
}
}
func TestTheBundleHandedATokenFileNeedsNoAdminLogin(t *testing.T) {
t.Setenv("MESH_GITEA_URL", "http://127.0.0.1:3000")
t.Setenv("MESH_GITEA_TOKEN_FILE", "/state/token")
t.Setenv("MESH_GITEA_TOOLS_USER", "")
t.Setenv("MESH_GITEA_ADMIN_USER", "")
t.Setenv("MESH_GITEA_ADMIN_PASSWORD_FILE", "")
f, err := ForgeFromEnv()
if err != nil {
t.Fatal(err)
}
if f.TokenFile != "/state/token" || f.Account != "mesh-tools" || f.User != "" || f.PasswordFile != "" {
t.Fatalf("%+v", f.Client)
}
}
+57 -8
View File
@@ -9,6 +9,8 @@ import (
"net/url"
"strings"
"unicode/utf8"
"gitea/internal/forgehttp"
)
// gitea_commit (novox/hq issue 375): one commit on a branch of its own, so an agent follows the branch / pull
@@ -33,6 +35,11 @@ type CommitAsk struct {
Message string
Files []FileChange
Patch string // a unified diff, as git diff writes it, instead of Files
// PatchFile is the name of a file in the spool directory holding the patch (novox/hq issue 852), with the
// SHA-256 its caller says it has; commitAsk reads it into Patch.
PatchFile string
PatchSHA256 string
patchFileGiven bool
}
// Committed is what gitea_commit made.
@@ -67,6 +74,11 @@ func readCommitAsk(args map[string]any) (CommitAsk, error) {
a := CommitAsk{Owner: text(args, "owner"), Repo: text(args, "repo"), Branch: text(args, "branch"),
Base: text(args, "base"), Message: strings.TrimSpace(raw(args, "message"))}
a.Patch = raw(args, "patch")
if v, ok := args["patch_file"]; ok && v != nil {
a.patchFileGiven = true
a.PatchFile = raw(args, "patch_file")
}
a.PatchSHA256 = strings.ToLower(strings.TrimSpace(raw(args, "patch_sha256")))
if a.Branch == "" {
return a, refuse("branch is required: the branch the commit goes on, a branch of its own (created from base when absent). Nothing was sent")
}
@@ -109,11 +121,23 @@ func readCommitAsk(args map[string]any) (CommitAsk, error) {
a.Files = append(a.Files, fc)
}
}
sources := 0
for _, g := range []bool{len(a.Files) > 0, strings.TrimSpace(a.Patch) != "", a.patchFileGiven} {
if g {
sources++
}
}
switch {
case len(a.Files) == 0 && strings.TrimSpace(a.Patch) == "":
return a, refuse("give files (each {path, content | content_base64 | delete: true}) or patch (a unified diff, as git diff writes it). Nothing was sent")
case len(a.Files) > 0 && strings.TrimSpace(a.Patch) != "":
return a, refuse("give files or patch, not both: one commit carries one of them. Nothing was sent")
case sources == 0:
return a, refuse("give files (each {path, content | content_base64 | delete: true}), patch (a unified diff, as git diff writes it) or patch_file (the name of a patch in the spool directory). Nothing was sent")
case sources > 1:
return a, refuse("give files, patch or patch_file, not more than one: one commit carries one of them. Nothing was sent")
case a.PatchSHA256 != "" && !a.patchFileGiven:
return a, refuse("patch_sha256 goes with patch_file only. Nothing was sent")
case a.patchFileGiven:
if err := checkPatchFileAsk(a); err != nil {
return a, err
}
case len(a.Files) > maxFiles:
return a, refuse("%d files is more than the %d one commit carries. Nothing was sent", len(a.Files), maxFiles)
case len(a.Patch) > maxPatchBytes:
@@ -132,13 +156,13 @@ func readCommitAsk(args map[string]any) (CommitAsk, error) {
return a, refuse("%s is named twice in files. Nothing was sent", fc.Path)
}
seen[fc.Path] = true
given := 0
have := 0
for _, g := range []bool{fc.Content != nil, fc.ContentBase64 != nil, fc.Delete} {
if g {
given++
have++
}
}
if given != 1 {
if have != 1 {
return a, refuse("files[%d] (%s) needs exactly one of content, content_base64 or delete: true. Nothing was sent", i, fc.Path)
}
if fc.Content != nil && !utf8.ValidString(*fc.Content) {
@@ -211,7 +235,32 @@ func commit(ctx context.Context, f *Forge, a CommitAsk) (*Committed, error) {
// Read the rules, or refuse: a branch the rules could not be read for is never written (fails closed).
var rules []Protection
if err := f.GetJSON(ctx, repo+"/branch_protections", &rules); err != nil {
return nil, fmt.Errorf("the protection rules of %s/%s could not be read, so the commit was not judged and nothing was sent: %w", a.Owner, a.Repo, err)
var status *forgehttp.StatusError
if !errors.As(err, &status) || status.Status != http.StatusForbidden {
return nil, fmt.Errorf("the protection rules of %s/%s could not be read, so the commit was not judged and nothing was sent: %w", a.Owner, a.Repo, err)
}
// The rules are a repository administrator's to read, and the tools' account is none (novox/hq ADR 0365
// §2). What it may read is the protection the forge applies to an existing branch: one protected is
// refused here. A branch not made yet is covered by no rule this account can see, and the forge itself
// judges the commit that makes it against every rule, refusing one a rule guards.
if err := targetRefused(a.Branch, r.DefaultBranch, nil); err != nil {
return nil, err
}
b, err := f.branch(ctx, repo, a.Branch)
if err != nil {
return nil, fmt.Errorf("whether a protection rule guards %s of %s/%s could not be read, so nothing was sent: %w", a.Branch, a.Owner, a.Repo, err)
}
if b != nil && b.Protected {
rule := b.Rule
if rule == "" {
rule = a.Branch
}
rules = []Protection{{RuleName: rule}}
if !ruleCovers(rule, a.Branch) {
return nil, refuse("a commit on %s is refused: the protection rule %s guards it (novox/hq ADR 0266). Put it on a "+
"branch of its own. Nothing was sent", a.Branch, rule)
}
}
}
if err := targetRefused(a.Branch, r.DefaultBranch, rules); err != nil {
return nil, err
+6 -3
View File
@@ -11,8 +11,8 @@ import (
"gitea/internal/forgehttp"
)
// The forge as this bundle reaches it: its own HTTP interface on the machine, as the forge's admin account
// (internal/forgehttp, shared with the npm-registry bundle beside it).
// The forge as this bundle reaches it: its own HTTP interface on the machine, as the tools' own account (novox/hq
// ADR 0365 §2; internal/forgehttp, shared with the npm-registry bundle beside it).
// Forge is the forge's HTTP interface.
type Forge struct {
@@ -91,12 +91,15 @@ func (p Protection) name() string {
return p.BranchName
}
// Branch is one branch with the commit it points at.
// Branch is one branch with the commit it points at, and the protection the forge applies to it: what an account
// that may not read the rules themselves (the tools' account, novox/hq ADR 0365) knows of them.
type Branch struct {
Name string `json:"name"`
Commit struct {
ID string `json:"id"`
} `json:"commit"`
Protected bool `json:"protected"`
Rule string `json:"effective_branch_protection_name"`
}
// branch is the named branch, nil when the forge has none of that name.
+7 -1
View File
@@ -619,11 +619,17 @@ func TestToolsAreTheOnesTheReadmeNamesAndTheManifestBuilds(t *testing.T) {
if len(a.Loads) != 1 || a.Loads[0] != a.Binary {
t.Errorf("the bundle loads %v, not its binary %s", a.Loads, a.Binary)
}
for _, k := range []string{"MESH_GITEA_URL", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD_FILE"} {
// The tools' own account, never the admin login (novox/hq ADR 0365 §2).
for _, k := range []string{"MESH_GITEA_URL", "MESH_GITEA_TOOLS_USER", "MESH_GITEA_TOKEN_FILE"} {
if a.Env[k] == "" {
t.Errorf("the bundle is not given %s", k)
}
}
for _, k := range []string{"MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD_FILE"} {
if a.Env[k] != "" {
t.Errorf("the bundle is given %s: it acts as the tools' account, not the administrator", k)
}
}
}
}
if !found {
+7 -3
View File
@@ -55,7 +55,9 @@ func Tools(forge func() (*Forge, error)) []stdio.Tool {
"the branch holds them — or patch, a unified diff as git diff writes it, which the forge applies. " +
"At most 200 files, 4 MiB each and 16 MiB together, or a 4 MiB patch. Answers the commit it made and " +
"the parent the forge made it on, read back from the branch, and says when the branch moved under " +
"it. Replaces git commit and git " +
"it. A patch too large to type goes as patch_file with patch_sha256: the name of a file in the " +
"spool directory (read once, applied as patch is, removed afterwards, never read from anywhere " +
"else). Replaces git commit and git " +
"push of a feature branch. (a)",
Input: map[string]any{
"type": "object",
@@ -79,7 +81,9 @@ func Tools(forge func() (*Forge, error)) []stdio.Tool {
"required": []string{"path"},
},
},
"patch": str("a unified diff of the change, as git diff or git format-patch writes it; give this or files"),
"patch": str("a unified diff of the change, as git diff or git format-patch writes it; give this, patch_file or files"),
"patch_file": str("for a patch too large to type: the NAME (32 to 64 characters of 0-9a-f, chosen at random by the caller, no path) of a file the caller wrote into the forge patch spool directory (the module's forge-patches directory); needs patch_sha256; give this, patch or files"),
"patch_sha256": str("with patch_file: the file's SHA-256, in hex; the commit is refused, and nothing is sent, when the file's differs"),
},
"required": []string{"owner", "repo", "branch", "message"},
},
@@ -88,7 +92,7 @@ func Tools(forge func() (*Forge, error)) []stdio.Tool {
if err != nil {
return nil, err
}
return commit(ctx, f, a)
return commitAsk(ctx, f, os.Getenv(patchDirEnv), a)
}),
},
{
+109
View File
@@ -0,0 +1,109 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"syscall"
)
// A patch too large to type into a call is handed over as a file (novox/hq issue 852). The caller writes it into
// one spool directory the module declares (mode 0733: anyone writes, nobody lists) and names it in patch_file,
// with the file's SHA-256. The tool runs as the machine's account, so it reads nothing but that directory: the
// name is 32 to 64 characters of [0-9a-f], so it holds no separator and no dot; the file must be a regular file
// (never a symlink); and its hash must be the caller's, because without the sticky bit another writer could have
// replaced it. The file is removed once taken, whether the commit was made or not.
const patchDirEnv = "MESH_GITEA_PATCH_DIR"
var (
patchFileName = regexp.MustCompile(`^[0-9a-f]{32,64}$`)
patchSHA = regexp.MustCompile(`^[0-9a-f]{64}$`)
)
// checkPatchFileAsk judges the arguments of a patch_file ask, before any file is looked at.
func checkPatchFileAsk(a CommitAsk) error {
if !patchFileName.MatchString(a.PatchFile) {
return refuse("patch_file %q is not a name: 32 to 64 characters of 0-9a-f, no path (the file's name in the spool directory, chosen at random). Nothing was sent", a.PatchFile)
}
if !patchSHA.MatchString(a.PatchSHA256) {
return refuse("patch_file needs patch_sha256: the file's SHA-256, 64 hex characters. Nothing was sent")
}
return nil
}
// commitAsk makes the commit the ask names; a patch_file is first read from the spool directory into the patch
// and removed afterwards.
func commitAsk(ctx context.Context, f *Forge, spool string, a CommitAsk) (*Committed, error) {
if !a.patchFileGiven {
return commit(ctx, f, a)
}
if err := checkPatchFileAsk(a); err != nil {
return nil, err
}
if spool == "" {
return nil, refuse("patch_file is not available: %s is not set, so there is no spool directory. Nothing was sent", patchDirEnv)
}
path := filepath.Join(spool, a.PatchFile)
defer func() {
// Whatever happened, the file is not left: removing a symlink removes the link, never its target.
if st, err := os.Lstat(path); err == nil && !st.IsDir() {
_ = os.Remove(path)
}
}()
body, err := readSpooled(path, a.PatchFile)
if err != nil {
return nil, err
}
got := sha256.Sum256(body)
if hex.EncodeToString(got[:]) != a.PatchSHA256 {
return nil, refuse("the file %s is not the patch the caller hashed: its SHA-256 is %s, patch_sha256 says %s. Nothing was sent",
a.PatchFile, hex.EncodeToString(got[:]), a.PatchSHA256)
}
a.Patch = string(body)
return commit(ctx, f, a)
}
// readSpooled reads a regular file of at most maxPatchBytes, never through a symlink.
func readSpooled(path, name string) ([]byte, error) {
st, err := os.Lstat(path)
switch {
case errors.Is(err, os.ErrNotExist):
return nil, refuse("patch_file %s: no such file in the spool directory. Nothing was sent", name)
case err != nil:
return nil, refuse("patch_file %s cannot be read: %v. Nothing was sent", name, err)
case !st.Mode().IsRegular():
return nil, refuse("patch_file %s is not a regular file (a symlink or a directory is never followed). Nothing was sent", name)
case st.Size() > maxPatchBytes:
return nil, refuse("patch_file %s is %s, more than the %s one commit carries: split it into several commits. Nothing was sent", name, mib(int(st.Size())), mib(maxPatchBytes))
}
fh, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if err != nil {
return nil, refuse("patch_file %s cannot be opened: %v. Nothing was sent", name, err)
}
defer fh.Close()
fst, err := fh.Stat()
if err != nil || !fst.Mode().IsRegular() {
return nil, refuse("patch_file %s is not a regular file. Nothing was sent", name)
}
// A hard link to a file elsewhere is no symlink, but it is not the caller's own file either.
if sys, ok := fst.Sys().(*syscall.Stat_t); !ok || sys.Nlink > 1 {
return nil, refuse("patch_file %s has more than one hard link: it must be a file of its own. Nothing was sent", name)
}
body, err := io.ReadAll(io.LimitReader(fh, maxPatchBytes+1))
switch {
case err != nil:
return nil, refuse("patch_file %s cannot be read: %v. Nothing was sent", name, err)
case len(body) > maxPatchBytes:
return nil, refuse("patch_file %s is more than the %s one commit carries: split it into several commits. Nothing was sent", name, mib(maxPatchBytes))
case strings.TrimSpace(string(body)) == "":
return nil, refuse("patch_file %s is empty. Nothing was sent", name)
}
return body, nil
}
+183
View File
@@ -0,0 +1,183 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
)
// novox/hq issue 852: a patch too large to type into a call is handed over as a file in one spool directory.
const spoolName = "0123456789abcdef0123456789abcdef"
const spoolPatch = "diff --git a/README.md b/README.md\n--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-hello\n+hi\n"
func sum(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) }
func spoolWith(t *testing.T, name, content string) string {
t.Helper()
dir := t.TempDir()
if name != "" {
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
return dir
}
func fileAsk(t *testing.T, name, sha string) CommitAsk {
t.Helper()
return ask(t, map[string]any{"branch": "fix/9", "patch_file": name, "patch_sha256": sha})
}
func TestAPatchFileIsAppliedAsThePatchWouldBeAndTheFileIsGone(t *testing.T) {
f := newFake(t)
dir := spoolWith(t, spoolName, spoolPatch)
got, err := commitAsk(context.Background(), f.serve(), dir, fileAsk(t, spoolName, sum(spoolPatch)))
if err != nil {
t.Fatal(err)
}
if len(f.patches) != 1 || f.patches[0]["content"] != spoolPatch || f.patches[0]["new_branch"] != "fix/9" || got.Commit != "p1" {
t.Fatalf("%v %+v", f.patches, got)
}
if _, err := os.Lstat(filepath.Join(dir, spoolName)); !os.IsNotExist(err) {
t.Fatalf("the file is still there: %v", err)
}
}
func TestAPatchFileIsRemovedWhenTheForgeRefusesIt(t *testing.T) {
f := newFake(t)
dir := spoolWith(t, spoolName, spoolPatch)
// the default branch is refused after the file was read
a := ask(t, map[string]any{"branch": "main", "patch_file": spoolName, "patch_sha256": sum(spoolPatch)})
if _, err := commitAsk(context.Background(), f.serve(), dir, a); err == nil {
t.Fatal("taken")
}
if _, err := os.Lstat(filepath.Join(dir, spoolName)); !os.IsNotExist(err) {
t.Fatalf("the file is still there: %v", err)
}
}
func TestAPatchFileThatCannotBeTakenIsRefusedBeforeAnythingIsSent(t *testing.T) {
outside := filepath.Join(t.TempDir(), "secret")
if err := os.WriteFile(outside, []byte(spoolPatch), 0o600); err != nil {
t.Fatal(err)
}
type tc struct {
name string
setup func(dir string)
file string
sha string
want string
}
for _, c := range []tc{
{"wrong sha", func(d string) { _ = os.WriteFile(filepath.Join(d, spoolName), []byte(spoolPatch), 0o600) }, spoolName, sum("other"), "sha256"},
{"not found", func(string) {}, spoolName, sum(spoolPatch), "no such file"},
{"symlink", func(d string) { _ = os.Symlink(outside, filepath.Join(d, spoolName)) }, spoolName, sum(spoolPatch), "regular file"},
{"hard link", func(d string) { _ = os.Link(outside, filepath.Join(d, spoolName)) }, spoolName, sum(spoolPatch), "hard link"},
{"directory", func(d string) { _ = os.Mkdir(filepath.Join(d, spoolName), 0o700) }, spoolName, sum(spoolPatch), "regular file"},
{"too large", func(d string) {
_ = os.WriteFile(filepath.Join(d, spoolName), []byte(strings.Repeat("x", maxPatchBytes+1)), 0o600)
}, spoolName, sum(spoolPatch), "MiB"},
{"empty", func(d string) { _ = os.WriteFile(filepath.Join(d, spoolName), nil, 0o600) }, spoolName, sum(""), "empty"},
} {
f := newFake(t)
dir := t.TempDir()
c.setup(dir)
_, err := commitAsk(context.Background(), f.serve(), dir, fileAsk(t, c.file, c.sha))
if err == nil || !strings.Contains(err.Error(), c.want) || !strings.Contains(err.Error(), "Nothing was sent") {
t.Errorf("%s: %v", c.name, err)
}
if len(f.patches)+len(f.writes) != 0 {
t.Errorf("%s: something was sent", c.name)
}
}
if b, err := os.ReadFile(outside); err != nil || string(b) != spoolPatch {
t.Errorf("the file outside the spool was touched: %v", err)
}
}
func TestAPatchFileAskThatCannotBeReadIsRefused(t *testing.T) {
ok := sum(spoolPatch)
for name, args := range map[string]map[string]any{
"no sha": {"patch_file": spoolName},
"sha not hex": {"patch_file": spoolName, "patch_sha256": strings.Repeat("z", 64)},
"sha too short": {"patch_file": spoolName, "patch_sha256": ok[:63]},
"empty name": {"patch_file": "", "patch_sha256": ok, "files": filesArg()},
"dotdot": {"patch_file": "../" + spoolName, "patch_sha256": ok},
"slash": {"patch_file": "a/" + spoolName, "patch_sha256": ok},
"absolute": {"patch_file": "/etc/passwd", "patch_sha256": ok},
"too short": {"patch_file": strings.Repeat("a", 31), "patch_sha256": ok},
"too long": {"patch_file": strings.Repeat("a", 65), "patch_sha256": ok},
"upper case": {"patch_file": strings.ToUpper(spoolName), "patch_sha256": ok},
"not hex": {"patch_file": strings.Repeat("g", 32), "patch_sha256": ok},
"with patch": {"patch_file": spoolName, "patch_sha256": ok, "patch": "diff"},
"with files": {"patch_file": spoolName, "patch_sha256": ok, "files": filesArg(map[string]any{"path": "x", "content": "x"})},
"sha without it": {"patch": "diff", "patch_sha256": ok},
} {
full := map[string]any{"owner": "acme", "repo": "app", "message": "m", "branch": "b"}
for k, v := range args {
full[k] = v
}
_, err := readCommitAsk(full)
if err == nil || !strings.Contains(err.Error(), "Nothing was sent") {
t.Errorf("%s: %v", name, err)
}
}
// 64 characters is taken; so is an upper-case sha.
for _, n := range []string{spoolName, strings.Repeat("ab", 32)} {
if _, err := readCommitAsk(map[string]any{"owner": "acme", "repo": "app", "message": "m", "branch": "b",
"patch_file": n, "patch_sha256": strings.ToUpper(sum(spoolPatch))}); err != nil {
t.Errorf("%s: %v", n, err)
}
}
}
func TestAPatchFileWithoutASpoolDirectoryIsRefused(t *testing.T) {
f := newFake(t)
_, err := commitAsk(context.Background(), f.serve(), "", fileAsk(t, spoolName, sum(spoolPatch)))
if err == nil || !strings.Contains(err.Error(), "MESH_GITEA_PATCH_DIR") || !strings.Contains(err.Error(), "Nothing was sent") {
t.Fatalf("%v", err)
}
}
func TestTheManifestDeclaresTheSpoolDirectoryAndGivesItToTheBundle(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Resources []struct {
ID, Type, Path, Mode, Owner string
} `json:"resources"`
Build struct {
Artifacts []struct {
From string `json:"from"`
Env map[string]string `json:"env"`
} `json:"artifacts"`
} `json:"build"`
}
if err := jsonInto(raw, &m); err != nil {
t.Fatal(err)
}
found := false
for _, r := range m.Resources {
if r.ID == "forge-patches" {
found = true
if r.Type != "directory" || r.Mode != "0733" || r.Owner != "${machine:account}" || r.Path == "" {
t.Errorf("%+v", r)
}
}
}
if !found {
t.Error("no forge-patches directory")
}
for _, a := range m.Build.Artifacts {
if a.From == "cmd/forge" && a.Env["MESH_GITEA_PATCH_DIR"] != "${dir:forge-patches}" {
t.Errorf("env %v", a.Env)
}
}
}
+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)
}
}