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
|
forge matches them; a code search stopped at its deadline; every malformed ask refused; and each read
|
||||||
summarised.
|
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
|
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;
|
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
|
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
|
on a machine, it
|
||||||
|
|
||||||
- makes the account `agent-<node>` with the administrator's login, when there is none: no administrator, a random
|
- 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
|
password sent once and kept nowhere. It is added to the team `agents` of the organisation `novox`, which the
|
||||||
to a team that reads more (§3). An account found made an administrator, inactive or barred from logging in is
|
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;
|
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
|
- deletes the account's token by the name `mesh-agent-read` (by its id, every one of them) and mints one whose
|
||||||
scope is `read:repository`: whatever team it is put in, the forge refuses it a push, a comment and any token or
|
scopes are `write:repository` and `write:issue`: the forge refuses it any token or administration of its own
|
||||||
administration of its own (§5). Every ask mints anew; that is its rotation;
|
(§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),
|
- 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
|
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`.
|
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
|
// 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). A process the node's runtime launches and speaks MCP over stdio to, beside the module's other bundles.
|
// 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
|
// 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
|
// 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
|
// agent a forge account of its own, `agent-<node>`, no administrator and a member of the `agents` write team, mints it a token whose
|
||||||
// scope is `read:repository`, and answers the git credential lines for it, each holder's sealed to the key the vault
|
// 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.
|
// 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.
|
// 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 {
|
func Tools(minter func() (*Minter, error)) []stdio.Tool {
|
||||||
return []stdio.Tool{{
|
return []stdio.Tool{{
|
||||||
Name: ToolName,
|
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 " +
|
"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 " +
|
"and a member of the agents team (write on every repository), and a token of scopes write:repository and " +
|
||||||
"sealed to seal_to. Any other caller is refused and nothing is minted.",
|
"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{
|
Input: map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
// ToolName is the issuing tool the vault asks (the offer's credential tool in module.json).
|
// ToolName is the issuing tool the vault asks (the offer's credential tool in module.json).
|
||||||
ToolName = "gitea_agent_token"
|
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"
|
Provision = "gitea-read"
|
||||||
// Holder is the one consuming module answered: the agent's own module, which places the credential in the
|
// 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.
|
// 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
|
deadline = 45 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// TokenScopes are the only scopes an agent's token carries (ADR 0365 §4, §5): it reads repositories and nothing
|
// TokenScopes are the only scopes an agent's token carries (ADR 0398 §1, issue 948): it writes repositories and
|
||||||
// else, so the forge refuses it a push, a comment, a token of its own and any administration — whatever team the
|
// issues and nothing else, so the forge refuses it a token of its own and any administration. Where it may write is
|
||||||
// account is in.
|
// the agents team's (a write team on every repository); main takes no push whatever the token (ADR 0398 §2).
|
||||||
var TokenScopes = []string{"read:repository"}
|
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}$`)
|
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 {
|
if err := m.ensureAccount(ctx, account); err != nil {
|
||||||
return Answer{}, err
|
return Answer{}, err
|
||||||
}
|
}
|
||||||
|
if err := m.ensureInTeam(ctx, account); err != nil {
|
||||||
|
return Answer{}, err
|
||||||
|
}
|
||||||
token, err := m.mint(ctx, account)
|
token, err := m.mint(ctx, account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Answer{}, err
|
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)
|
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)})
|
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
|
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
|
// 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
|
// an administrator, active, able to use its token. Its team is ensureInTeam's.
|
||||||
// repositories is the operator's to grant (ADR 0365 §3).
|
|
||||||
func (m *Minter) ensureAccount(ctx context.Context, account string) error {
|
func (m *Minter) ensureAccount(ctx context.Context, account string) error {
|
||||||
var u forgeUser
|
var u forgeUser
|
||||||
err := m.Forge.GetJSON(ctx, "/api/v1/users/"+account, &u)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("the account %s could not be made: %w", account, err)
|
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
|
return nil
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return fmt.Errorf("the account %s could not be read: %w", account, err)
|
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
|
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 {
|
type forgeToken struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
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
|
// 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) {
|
func (m *Minter) mint(ctx context.Context, account string) (string, error) {
|
||||||
var held []forgeToken
|
var held []forgeToken
|
||||||
if err := m.Forge.GetJSON(ctx, "/api/v1/users/"+account+"/tokens?limit=50", &held); err != nil {
|
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
|
made map[string]map[string]any
|
||||||
minted map[string][]string // account -> scopes of its last mint
|
minted map[string][]string // account -> scopes of its last mint
|
||||||
failMint bool
|
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 {
|
func newFakeForge() *fakeForge {
|
||||||
return &fakeForge{users: map[string]*forgeUser{}, tokens: map[string][]forgeToken{}, made: map[string]map[string]any{},
|
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+))?$`)
|
var tokenPath = regexp.MustCompile(`^/api/v1/users/([^/]+)/tokens(?:/(\d+))?$`)
|
||||||
|
|
||||||
func (f *fakeForge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
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.Active, _ = in["active"].(bool)
|
||||||
u.ProhibitLogin, _ = in["prohibit_login"].(bool)
|
u.ProhibitLogin, _ = in["prohibit_login"].(bool)
|
||||||
reply(200, map[string]any{})
|
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):
|
case tokenPath.MatchString(r.URL.Path):
|
||||||
m := tokenPath.FindStringSubmatch(r.URL.Path)
|
m := tokenPath.FindStringSubmatch(r.URL.Path)
|
||||||
account := m[1]
|
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)
|
h := newHarness(t)
|
||||||
ask, err := readAsk(h.args(HolderAsk{"anchor", "claude-code"}, HolderAsk{"laptop", "claude-code"}, HolderAsk{"laptop", "lab"}))
|
ask, err := readAsk(h.args(HolderAsk{"anchor", "claude-code"}, HolderAsk{"laptop", "claude-code"}, HolderAsk{"laptop", "lab"}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -240,15 +281,23 @@ func TestEachAgentGetsAnAccountOfItsOwnAndAReadTokenSealedForItAlone(t *testing.
|
|||||||
if made := h.forge.made[account]; made["must_change_password"] != false || made["password"] == "" {
|
if made := h.forge.made[account]; made["must_change_password"] != false || made["password"] == "" {
|
||||||
t.Errorf("%s was made with %v", account, made)
|
t.Errorf("%s was made with %v", account, made)
|
||||||
}
|
}
|
||||||
if !slices.Equal(h.forge.minted[account], []string{"read:repository"}) {
|
if !slices.Equal(h.forge.minted[account], []string{"write:repository", "write:issue"}) {
|
||||||
t.Errorf("%s's token carries %v, want read:repository alone", account, h.forge.minted[account])
|
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 {
|
for _, c := range h.forge.calls {
|
||||||
if strings.Contains(c, "/teams") || strings.Contains(c, "/orgs/") || strings.Contains(c, "collaborators") {
|
if 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)
|
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 {
|
if _, made := h.forge.users["agent-laptop-lab"]; made || len(h.forge.users) != 2 {
|
||||||
t.Errorf("accounts made: %v", h.forge.users)
|
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]
|
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