vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Let an account be a bot
29a795b9d5b79cf5c8f2b7f3b4f6572f7cdff444 · Keenan Nemetz · 7d ago · parent e7aa0785
Let an account be a bot An automation needs an identity: something to assign an issue to, mention in a comment, and name in an approval and the audit log. An account already gives all of that, so this adds only the invariants a bot needs and the word for it. A bot is created with `vole admin account create --bot`, with the repository role the admin decides it should have. It has no browser sign-in — `account invite` and adding a passkey are refused — and it cannot hold the instance-admin role, because Admin reaches every repository and every instance surface while a bot is a repository automation. It is marked as a bot in the user directory and on its profile. Everything else is deliberately unchanged. A bot obeys the rules of its role: a contribute bot pushes only its own contributor branch and its CI waits for a maintainer to allow the tip; a maintain bot pushes shared branches, runs CI immediately, and its approval counts. The kind is fixed at creation, like the name, and a roster that predates it reads every account as a person. This is the first of three slices of #165. The per-run credential that lets a workflow act as a bot without a long-lived key in a repository secret is next; this slice changes no push rule and no permission.
forge/identity/identity_test.go +69
| @@ -508,6 +508,11 @@ INSERT INTO lfs_locks(id, repo_id, path, owner, locked_at) VALUES('lock-1', 'rep | |||
| 508 | 508 | if a.Name != "wilbur" || !a.Admin || len(a.Keys) != 1 { | |
| 509 | 509 | t.Fatalf("upgraded account = %+v", a) | |
| 510 | 510 | } | |
| 511 | // An account that predates the kind column is a person: defaulting an upgrade | ||
| 512 | // to bot would turn every operator into an automation that cannot sign in. | ||
| 513 | if a.Kind != KindPerson || a.Bot() { | ||
| 514 | t.Fatalf("upgraded account kind = %q, want %q", a.Kind, KindPerson) | ||
| 515 | } | ||
| 511 | 516 | if name, ok := s.Lookup(a.Keys[0].Fingerprint); !ok || name != "wilbur" { | |
| 512 | 517 | t.Fatalf("upgraded key resolves to %q,%v", name, ok) | |
| 513 | 518 | } | |
| @@ -751,3 +756,67 @@ func TestMutationsFailWhenTheLogCannotBeWritten(t *testing.T) { | |||
| 751 | 756 | t.Fatal("the account was created anyway") | |
| 752 | 757 | } | |
| 753 | 758 | } | |
| 759 | |||
| 760 | // A bot is an account with no browser: it can hold a repository role and a key, it | ||
| 761 | // cannot be given a passkey or an enrollment link, and it cannot be an instance | ||
| 762 | // admin. The kind is fixed at creation, like the name. | ||
| 763 | func TestBotAccountsHaveNoBrowser(t *testing.T) { | ||
| 764 | t.Parallel() | ||
| 765 | s := newStore(t) | ||
| 766 | line, _ := pubLine(t) | ||
| 767 | |||
| 768 | bot, err := s.CreateBot("coding-agent", access.Contribute, line, "admin") | ||
| 769 | if err != nil { | ||
| 770 | t.Fatalf("create bot: %v", err) | ||
| 771 | } | ||
| 772 | if !bot.Bot() || bot.Kind != KindBot { | ||
| 773 | t.Fatalf("kind = %q, want %q", bot.Kind, KindBot) | ||
| 774 | } | ||
| 775 | // The roster carries the kind, so every reader of Snapshot sees it without a | ||
| 776 | // second lookup. | ||
| 777 | if got := s.Snapshot().ByName["coding-agent"]; got == nil || !got.Bot() { | ||
| 778 | t.Fatalf("the roster lost the kind: %+v", got) | ||
| 779 | } | ||
| 780 | // A person is unchanged: the default is what an upgrade gives every existing | ||
| 781 | // account. | ||
| 782 | person := mustCreate(t, s, "alice") | ||
| 783 | if person.Bot() || person.Kind != KindPerson { | ||
| 784 | t.Fatalf("a person became %q", person.Kind) | ||
| 785 | } | ||
| 786 | |||
| 787 | // No passkey, no invite link: a bot has no browser to sign in from, so a | ||
| 788 | // browser credential would be a secret nobody can use. | ||
| 789 | cred := testCredential(t, "phone") | ||
| 790 | if err := s.AddCredential("coding-agent", cred, "admin"); err == nil { | ||
| 791 | t.Fatal("a bot account was given a passkey") | ||
| 792 | } | ||
| 793 | if _, err := s.Invite("coding-agent", "admin", 0); err == nil { | ||
| 794 | t.Fatal("a bot account was given an enrollment link") | ||
| 795 | } | ||
| 796 | |||
| 797 | // An instance admin reaches every repository; a bot's role is a repository | ||
| 798 | // grant, so admin is refused at creation and when changing the role later. | ||
| 799 | if _, err := s.CreateBot("admin-bot", access.Admin, "", "admin"); err == nil { | ||
| 800 | t.Fatal("a bot account was created as an instance admin") | ||
| 801 | } | ||
| 802 | if _, err := s.SetInstanceRole("coding-agent", access.Admin, "admin"); err == nil { | ||
| 803 | t.Fatal("a bot account was promoted to instance admin") | ||
| 804 | } | ||
| 805 | } | ||
| 806 | |||
| 807 | // A bot with a repository role is otherwise an ordinary account: it can be | ||
| 808 | // assigned, mentioned, and found by name. | ||
| 809 | func TestABotIsAnAccount(t *testing.T) { | ||
| 810 | t.Parallel() | ||
| 811 | s := newStore(t) | ||
| 812 | if _, err := s.CreateBot("coding-agent", access.None, "", "admin"); err != nil { | ||
| 813 | t.Fatalf("create bot without a key: %v", err) | ||
| 814 | } | ||
| 815 | got, err := s.Find("coding-agent") | ||
| 816 | if err != nil || got == nil || !got.Bot() { | ||
| 817 | t.Fatalf("find bot: %+v %v", got, err) | ||
| 818 | } | ||
| 819 | if len(got.Keys) != 0 { | ||
| 820 | t.Fatalf("a keyless bot has keys: %v", got.Keys) | ||
| 821 | } | ||
| 822 | } | ||