Give the agent's token write scopes and the agents write team (issue 948, ADR 0398 §1)
This commit is contained in:
@@ -102,21 +102,23 @@ changed under the commit refused; a commit over its size limits refused; protect
|
||||
forge matches them; a code search stopped at its deadline; every malformed ask refused; and each read
|
||||
summarised.
|
||||
|
||||
## Each agent's read credential (novox/hq ADR 0365 §1, §3–§5, issue 737)
|
||||
## Each agent's credential (novox/hq ADR 0365 §1, §3–§5, issue 737; ADR 0398 §1, issue 948)
|
||||
|
||||
An agent clones and fetches the repositories it may read as a forge account of its own, `agent-<node>`. The
|
||||
An agent clones, fetches and pushes branches of the repositories as a forge account of its own, `agent-<node>`. The
|
||||
module offers the provision `gitea-read`, whose credential its software issues (ADR 0305) one per holder (issue
|
||||
749): the Go bundle `cmd/agent-token` serves `gitea_agent_token`, which the vault alone asks (ADR 0349 clause 10;
|
||||
any other caller is refused and nothing is minted). For each holder the vault names that is the claude-code module
|
||||
on a machine, it
|
||||
|
||||
- makes the account `agent-<node>` with the administrator's login, when there is none: no administrator, a random
|
||||
password sent once and kept nowhere, and **in no team** — it reads public repositories until the operator adds it
|
||||
to a team that reads more (§3). An account found made an administrator, inactive or barred from logging in is
|
||||
password sent once and kept nowhere. It is added to the team `agents` of the organisation `novox`, which the
|
||||
administrator's login makes when there is none and puts back to its shape when found changed: write on code, issues
|
||||
and pull requests of every repository, no right to make repositories (the shape of the forge tools' team). The
|
||||
operator's read teams (`agent-read-*`, ADR 0365 §3) are `cmd/agent-teams`'s and stay. An account found made an administrator, inactive or barred from logging in is
|
||||
made a plain account again;
|
||||
- deletes the account's token by the name `mesh-agent-read` (by its id, every one of them) and mints one whose one
|
||||
scope is `read:repository`: whatever team it is put in, the forge refuses it a push, a comment and any token or
|
||||
administration of its own (§5). Every ask mints anew; that is its rotation;
|
||||
- deletes the account's token by the name `mesh-agent-read` (by its id, every one of them) and mints one whose
|
||||
scopes are `write:repository` and `write:issue`: the forge refuses it any token or administration of its own
|
||||
(§5), and the main branch of every repository takes no push from it (ADR 0398 §2). Every ask mints anew; that is its rotation;
|
||||
- answers git's credential-store lines for it — the forge's public origin, read from `origins` (the route's name),
|
||||
and on the forge's own machine its address there too — sealed to the key the vault made for the call. A holder of
|
||||
another module is not answered and said under `skipped`.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// 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.
|
||||
// agent-token: the forge's credential for each node's agent (novox/hq ADR 0365 clauses 1, 3, 4 and 5, ADR 0398 §1; issues
|
||||
// 737, 948). 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
|
||||
// agent a forge account of its own, `agent-<node>`, no administrator and a member of the `agents` write team, mints it a token whose
|
||||
// scopes are `write:repository` and `write:issue`, 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.
|
||||
@@ -33,10 +33,10 @@ func main() {
|
||||
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 " +
|
||||
Description: "The forge's credential for each node's agent (novox/hq ADR 0365, ADR 0398), 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.",
|
||||
"and a member of the agents team (write on every repository), and a token of scopes write:repository and " +
|
||||
"write:issue 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{
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
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 is what the tool issues: the forge's agent credential (named gitea-read from before it wrote), 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.
|
||||
@@ -38,10 +38,26 @@ const (
|
||||
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"}
|
||||
// TokenScopes are the only scopes an agent's token carries (ADR 0398 §1, issue 948): it writes repositories and
|
||||
// issues and nothing else, so the forge refuses it a token of its own and any administration. Where it may write is
|
||||
// the agents team's (a write team on every repository); main takes no push whatever the token (ADR 0398 §2).
|
||||
var TokenScopes = []string{"write:repository", "write:issue"}
|
||||
|
||||
const (
|
||||
// TeamOrg is the organisation whose repositories are the mesh's.
|
||||
TeamOrg = "novox"
|
||||
// TeamName is the one team every agent's account is in (ADR 0398 §1).
|
||||
TeamName = "agents"
|
||||
)
|
||||
|
||||
// teamShape is what the agents team grants: write on code, issues and pull requests of every repository of the
|
||||
// organisation and no more, the shape of the forge tools' team (tools-account.ts).
|
||||
func teamShape() map[string]any {
|
||||
return map[string]any{
|
||||
"name": TeamName, "permission": "write", "includes_all_repositories": true, "can_create_org_repo": false,
|
||||
"units_map": map[string]string{"repo.code": "write", "repo.issues": "write", "repo.pulls": "write"},
|
||||
}
|
||||
}
|
||||
|
||||
var nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
|
||||
|
||||
@@ -193,6 +209,9 @@ func (m *Minter) Answer(ask Ask) (Answer, error) {
|
||||
if err := m.ensureAccount(ctx, account); err != nil {
|
||||
return Answer{}, err
|
||||
}
|
||||
if err := m.ensureInTeam(ctx, account); err != nil {
|
||||
return Answer{}, err
|
||||
}
|
||||
token, err := m.mint(ctx, account)
|
||||
if err != nil {
|
||||
return Answer{}, err
|
||||
@@ -208,7 +227,7 @@ func (m *Minter) Answer(ask Ask) (Answer, error) {
|
||||
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)
|
||||
m.say("minted a write token for %s (%s on %s); its token from before is deleted", account, h.Module, h.Node)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -264,8 +283,7 @@ type forgeUser struct {
|
||||
}
|
||||
|
||||
// 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).
|
||||
// an administrator, active, able to use its token. Its team is ensureInTeam's.
|
||||
func (m *Minter) ensureAccount(ctx context.Context, account string) error {
|
||||
var u forgeUser
|
||||
err := m.Forge.GetJSON(ctx, "/api/v1/users/"+account, &u)
|
||||
@@ -280,7 +298,7 @@ func (m *Minter) ensureAccount(ctx context.Context, account string) error {
|
||||
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)
|
||||
m.say("made the forge account %s for that machine's agent, in no team yet", account)
|
||||
return nil
|
||||
case err != nil:
|
||||
return fmt.Errorf("the account %s could not be read: %w", account, err)
|
||||
@@ -297,6 +315,50 @@ func (m *Minter) ensureAccount(ctx context.Context, account string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureInTeam makes the agents team when there is none, puts a team that is there back to its shape, and adds the
|
||||
// account to it; an account already in it stays (ADR 0398 §1, issue 948).
|
||||
func (m *Minter) ensureInTeam(ctx context.Context, account string) error {
|
||||
var id int64
|
||||
for page := 1; page <= 20 && id == 0; page++ {
|
||||
var teams []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := m.Forge.GetJSON(ctx, fmt.Sprintf("/api/v1/orgs/%s/teams?limit=50&page=%d", TeamOrg, page), &teams); err != nil {
|
||||
return fmt.Errorf("the teams of %s could not be read: %w", TeamOrg, err)
|
||||
}
|
||||
for _, t := range teams {
|
||||
if t.Name == TeamName {
|
||||
id = t.ID
|
||||
}
|
||||
}
|
||||
if len(teams) < 50 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if id != 0 {
|
||||
if err := m.Forge.SendJSON(ctx, http.MethodPatch, fmt.Sprintf("/api/v1/teams/%d", id), teamShape(), nil); err != nil {
|
||||
return fmt.Errorf("the team %s could not be put back to its shape: %w", TeamName, err)
|
||||
}
|
||||
} else {
|
||||
var made struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
if err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/orgs/"+TeamOrg+"/teams", teamShape(), &made); err != nil {
|
||||
return fmt.Errorf("the team %s could not be made: %w", TeamName, err)
|
||||
}
|
||||
id = made.ID
|
||||
m.say("made the forge team %s in %s: write on every repository", TeamName, TeamOrg)
|
||||
}
|
||||
if id == 0 {
|
||||
return fmt.Errorf("the forge answered no id for the team %s", TeamName)
|
||||
}
|
||||
if err := m.Forge.SendJSON(ctx, http.MethodPut, fmt.Sprintf("/api/v1/teams/%d/members/%s", id, account), nil, nil); err != nil {
|
||||
return fmt.Errorf("the account %s could not be added to the team %s: %w", account, TeamName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type forgeToken struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -304,7 +366,7 @@ type forgeToken struct {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// refused), and mints one with the two write scopes 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 {
|
||||
|
||||
@@ -33,13 +33,20 @@ type fakeForge struct {
|
||||
made map[string]map[string]any
|
||||
minted map[string][]string // account -> scopes of its last mint
|
||||
failMint bool
|
||||
// teams by name; members by team name (issue 948).
|
||||
teams map[string]map[string]any
|
||||
teamIDs map[string]int64
|
||||
members map[string][]string
|
||||
}
|
||||
|
||||
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}
|
||||
minted: map[string][]string{}, next: 1,
|
||||
teams: map[string]map[string]any{}, teamIDs: map[string]int64{}, members: map[string][]string{}}
|
||||
}
|
||||
|
||||
var teamPath = regexp.MustCompile(`^/api/v1/teams/(\d+)(?:/members/([^/]+))?$`)
|
||||
|
||||
var tokenPath = regexp.MustCompile(`^/api/v1/users/([^/]+)/tokens(?:/(\d+))?$`)
|
||||
|
||||
func (f *fakeForge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -80,6 +87,40 @@ func (f *fakeForge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
u.Active, _ = in["active"].(bool)
|
||||
u.ProhibitLogin, _ = in["prohibit_login"].(bool)
|
||||
reply(200, map[string]any{})
|
||||
case r.URL.Path == "/api/v1/orgs/novox/teams":
|
||||
if r.Method == http.MethodGet {
|
||||
var list []map[string]any
|
||||
for name, id := range f.teamIDs {
|
||||
list = append(list, map[string]any{"id": id, "name": name})
|
||||
}
|
||||
reply(200, list)
|
||||
return
|
||||
}
|
||||
name, _ := in["name"].(string)
|
||||
f.teams[name] = in
|
||||
f.teamIDs[name] = f.next
|
||||
f.next++
|
||||
reply(201, map[string]any{"id": f.teamIDs[name], "name": name})
|
||||
case teamPath.MatchString(r.URL.Path):
|
||||
m := teamPath.FindStringSubmatch(r.URL.Path)
|
||||
for name, id := range f.teamIDs {
|
||||
if fmt.Sprint(id) != m[1] {
|
||||
continue
|
||||
}
|
||||
if m[2] == "" && r.Method == http.MethodPatch {
|
||||
f.teams[name] = in
|
||||
reply(200, map[string]any{"id": id})
|
||||
return
|
||||
}
|
||||
if m[2] != "" && r.Method == http.MethodPut {
|
||||
if !slices.Contains(f.members[name], m[2]) {
|
||||
f.members[name] = append(f.members[name], m[2])
|
||||
}
|
||||
reply(204, nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
reply(404, map[string]any{"message": "no such team"})
|
||||
case tokenPath.MatchString(r.URL.Path):
|
||||
m := tokenPath.FindStringSubmatch(r.URL.Path)
|
||||
account := m[1]
|
||||
@@ -199,7 +240,7 @@ func TestOnlyTheVaultIsAnsweredAndNothingIsMintedForAnyoneElse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachAgentGetsAnAccountOfItsOwnAndAReadTokenSealedForItAlone(t *testing.T) {
|
||||
func TestEachAgentGetsAnAccountOfItsOwnAndAWriteTokenSealedForItAlone(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 {
|
||||
@@ -240,15 +281,23 @@ func TestEachAgentGetsAnAccountOfItsOwnAndAReadTokenSealedForItAlone(t *testing.
|
||||
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])
|
||||
if !slices.Equal(h.forge.minted[account], []string{"write:repository", "write:issue"}) {
|
||||
t.Errorf("%s's token carries %v, want write:repository and write:issue alone (issue 948)", 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 strings.Contains(c, "collaborators") {
|
||||
t.Errorf("an agent's account was given access one repository at a time (%s) (issue 948)", c)
|
||||
}
|
||||
}
|
||||
for _, account := range []string{"agent-anchor", "agent-laptop"} {
|
||||
if !slices.Contains(h.forge.members["agents"], account) {
|
||||
t.Errorf("%s is not in the agents team: %v (issue 948, ADR 0398 §1)", account, h.forge.members)
|
||||
}
|
||||
}
|
||||
if len(h.forge.members) != 1 {
|
||||
t.Errorf("an agent was put in a team but agents: %v", h.forge.members)
|
||||
}
|
||||
if _, made := h.forge.users["agent-laptop-lab"]; made || len(h.forge.users) != 2 {
|
||||
t.Errorf("accounts made: %v", h.forge.users)
|
||||
}
|
||||
@@ -374,3 +423,40 @@ func tokenIn(t *testing.T, lines, account string) string {
|
||||
}
|
||||
return all[0][1]
|
||||
}
|
||||
|
||||
func TestTheAgentsTeamIsMadeWithWriteOnEveryRepositoryAndReusedWhenThere(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
ask, _ := readAsk(h.args(HolderAsk{"laptop", "claude-code"}))
|
||||
if _, err := h.minter.Answer(ask); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
team := h.forge.teams["agents"]
|
||||
if team == nil {
|
||||
t.Fatalf("no agents team was made (issue 948): %v", h.forge.teams)
|
||||
}
|
||||
if team["permission"] != "write" || team["includes_all_repositories"] != true || team["can_create_org_repo"] != false {
|
||||
t.Errorf("the agents team is %v, want write on all repositories and no repository creation", team)
|
||||
}
|
||||
units, _ := team["units_map"].(map[string]any)
|
||||
for _, u := range []string{"repo.code", "repo.issues", "repo.pulls"} {
|
||||
if units[u] != "write" {
|
||||
t.Errorf("unit %s is %v, want write", u, units[u])
|
||||
}
|
||||
}
|
||||
id := h.forge.teamIDs["agents"]
|
||||
// Changed by hand, then asked again for another node: reused, put back, the new account added.
|
||||
h.forge.teams["agents"] = map[string]any{"name": "agents", "permission": "read"}
|
||||
ask, _ = readAsk(h.args(HolderAsk{"laptop", "claude-code"}, HolderAsk{"anchor", "claude-code"}))
|
||||
if _, err := h.minter.Answer(ask); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h.forge.teamIDs["agents"] != id || len(h.forge.teamIDs) != 1 {
|
||||
t.Errorf("the team was made again: %v", h.forge.teamIDs)
|
||||
}
|
||||
if h.forge.teams["agents"]["permission"] != "write" {
|
||||
t.Errorf("the team was not put back to write: %v", h.forge.teams["agents"])
|
||||
}
|
||||
if !slices.Equal(h.forge.members["agents"], []string{"agent-laptop", "agent-anchor"}) {
|
||||
t.Errorf("members %v, each once", h.forge.members["agents"])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user