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 `.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://): 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...) }