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:
@@ -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)
|
||||
},
|
||||
}}
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
Reference in New Issue
Block a user