748048be50
Sessions run claude with --dangerously-skip-permissions and a prompt built from issue/PR/comment text, so only trusted authors may supply that text. teabot now dispatches a job only when the triggering event's author login is on an allowlist; an empty allowlist dispatches nothing (fail-closed). Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
340 lines
11 KiB
Go
340 lines
11 KiB
Go
// Package config loads and validates teabot's daemon configuration and the
|
|
// per-personality tea config files it references.
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const (
|
|
// AppName is used for XDG config/state directory names.
|
|
AppName = "teabot"
|
|
|
|
// DefaultGiteaURL is the Gitea instance teabot watches by default.
|
|
DefaultGiteaURL = "https://git.unkin.net"
|
|
|
|
// DefaultJobImage already ships claude-code plus a Go/Node/Python/tea
|
|
// developer toolchain, so teabot reuses it instead of building its own.
|
|
DefaultJobImage = "git.unkin.net/unkin/agent-dev:latest"
|
|
|
|
// DefaultPollInterval is how often each repo is polled when unset.
|
|
DefaultPollInterval = 60 * time.Second
|
|
|
|
// DefaultJobTimeout bounds a single Claude session.
|
|
DefaultJobTimeout = 30 * time.Minute
|
|
|
|
// DefaultMaxConcurrent caps simultaneously running job containers.
|
|
DefaultMaxConcurrent = 2
|
|
|
|
// DefaultContainerHome is the home directory inside the job image
|
|
// (the agent-dev image runs as the unprivileged "agent" user).
|
|
DefaultContainerHome = "/home/agent"
|
|
)
|
|
|
|
// Role describes what work a personality is allowed to perform.
|
|
type Role string
|
|
|
|
const (
|
|
// RoleImplementer handles new issues and issue-comment follow-ups.
|
|
RoleImplementer Role = "implementer"
|
|
// RoleReviewer handles new pull requests and PR-comment follow-ups.
|
|
RoleReviewer Role = "reviewer"
|
|
// RoleBoth handles every event kind.
|
|
RoleBoth Role = "both"
|
|
)
|
|
|
|
// Personality is a distinct Gitea bot identity backed by its own tea config
|
|
// file. Different personalities let, for example, an "implementer" account open
|
|
// PRs while a separate "reviewer" account critiques them.
|
|
type Personality struct {
|
|
// Name is the human-readable label used in logs and prompts.
|
|
Name string `yaml:"name"`
|
|
// TeaConfig is the path to a tea config.yml holding this bot's Gitea
|
|
// login (token + url + username). teabot parses it for the API token and
|
|
// mounts it into the job container so tea acts as this identity.
|
|
TeaConfig string `yaml:"tea_config"`
|
|
// Role gates which event kinds this personality reacts to.
|
|
Role Role `yaml:"role"`
|
|
// GitName / GitEmail set the commit identity inside the container.
|
|
GitName string `yaml:"git_name"`
|
|
GitEmail string `yaml:"git_email"`
|
|
|
|
// Login is populated at load time from the parsed tea config: the Gitea
|
|
// username. Events authored by any personality's login are ignored so the
|
|
// bot never reacts to its own comments (loop prevention).
|
|
Login string `yaml:"-"`
|
|
// Token is populated at load time from the parsed tea config: the API
|
|
// token used for polling as this identity. Never written back to disk.
|
|
Token string `yaml:"-"`
|
|
// URL is populated at load time from the parsed tea config: the instance
|
|
// URL of this login.
|
|
URL string `yaml:"-"`
|
|
}
|
|
|
|
// CanImplement reports whether the personality reacts to issue events.
|
|
func (p Personality) CanImplement() bool { return p.Role == RoleImplementer || p.Role == RoleBoth }
|
|
|
|
// CanReview reports whether the personality reacts to pull-request events.
|
|
func (p Personality) CanReview() bool { return p.Role == RoleReviewer || p.Role == RoleBoth }
|
|
|
|
// Config is teabot's top-level configuration (~/.config/teabot/config.yaml).
|
|
type Config struct {
|
|
// GiteaURL is the base URL of the Gitea instance to poll.
|
|
GiteaURL string `yaml:"gitea_url"`
|
|
// Repos is the list of owner/name repositories to watch.
|
|
Repos []string `yaml:"repos"`
|
|
// PollInterval is the delay between poll cycles.
|
|
PollInterval time.Duration `yaml:"poll_interval"`
|
|
// StateDir overrides the XDG state directory used to persist processed
|
|
// events. Empty means $XDG_STATE_HOME/teabot (default ~/.local/state/teabot).
|
|
StateDir string `yaml:"state_dir"`
|
|
// MaxConcurrent caps simultaneously running job containers.
|
|
MaxConcurrent int `yaml:"max_concurrent"`
|
|
// JobTimeout bounds a single dispatched Claude session.
|
|
JobTimeout time.Duration `yaml:"job_timeout"`
|
|
// JobImage is the container image each session runs in.
|
|
JobImage string `yaml:"job_image"`
|
|
// ContainerHome is the home directory inside JobImage that mounts target.
|
|
ContainerHome string `yaml:"container_home"`
|
|
|
|
// ClaudeConfigDir is the host directory holding Claude Code credentials
|
|
// (subscription auth), mounted into each container. Empty means ~/.claude.
|
|
ClaudeConfigDir string `yaml:"claude_config_dir"`
|
|
// AnthropicAPIKey, when set, is injected as ANTHROPIC_API_KEY instead of
|
|
// relying on the mounted subscription credentials.
|
|
AnthropicAPIKey string `yaml:"anthropic_api_key"`
|
|
// AnthropicBaseURL, when set, is injected as ANTHROPIC_BASE_URL.
|
|
AnthropicBaseURL string `yaml:"anthropic_base_url"`
|
|
|
|
// AllowedAuthors is the global allowlist of Gitea usernames whose issues,
|
|
// PRs, and comments may trigger a Claude job. This is a SECURITY control:
|
|
// jobs run with --dangerously-skip-permissions in a container whose prompt
|
|
// is built from event text, so only trusted authors may supply that text.
|
|
// An empty/absent allowlist is fail-closed — nothing is dispatched.
|
|
AllowedAuthors []string `yaml:"allowed_authors"`
|
|
// RepoAllowedAuthors optionally overrides AllowedAuthors per repository
|
|
// (keyed by "owner/name"). A present entry fully replaces the global list
|
|
// for that repo (even when empty, which disables dispatch for it); an
|
|
// absent entry falls back to AllowedAuthors.
|
|
RepoAllowedAuthors map[string][]string `yaml:"repo_allowed_authors"`
|
|
|
|
// Personalities are the bot identities teabot dispatches as.
|
|
Personalities []Personality `yaml:"personalities"`
|
|
}
|
|
|
|
// Load reads and validates the config at path, applying defaults and resolving
|
|
// each personality's tea config into a token/login/url.
|
|
func Load(path string) (*Config, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading config %s: %w", path, err)
|
|
}
|
|
cfg := &Config{}
|
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
|
return nil, fmt.Errorf("parsing config %s: %w", path, err)
|
|
}
|
|
cfg.applyDefaults()
|
|
if err := cfg.resolvePersonalities(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := cfg.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func (c *Config) applyDefaults() {
|
|
if c.GiteaURL == "" {
|
|
c.GiteaURL = DefaultGiteaURL
|
|
}
|
|
c.GiteaURL = strings.TrimRight(c.GiteaURL, "/")
|
|
if c.PollInterval <= 0 {
|
|
c.PollInterval = DefaultPollInterval
|
|
}
|
|
if c.MaxConcurrent <= 0 {
|
|
c.MaxConcurrent = DefaultMaxConcurrent
|
|
}
|
|
if c.JobTimeout <= 0 {
|
|
c.JobTimeout = DefaultJobTimeout
|
|
}
|
|
if c.JobImage == "" {
|
|
c.JobImage = DefaultJobImage
|
|
}
|
|
if c.ContainerHome == "" {
|
|
c.ContainerHome = DefaultContainerHome
|
|
}
|
|
if c.ClaudeConfigDir == "" {
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
c.ClaudeConfigDir = filepath.Join(home, ".claude")
|
|
}
|
|
} else {
|
|
c.ClaudeConfigDir = expandHome(c.ClaudeConfigDir)
|
|
}
|
|
if c.StateDir != "" {
|
|
c.StateDir = expandHome(c.StateDir)
|
|
}
|
|
for i := range c.Personalities {
|
|
if c.Personalities[i].Role == "" {
|
|
c.Personalities[i].Role = RoleBoth
|
|
}
|
|
c.Personalities[i].TeaConfig = expandHome(c.Personalities[i].TeaConfig)
|
|
}
|
|
}
|
|
|
|
// resolvePersonalities parses each personality's tea config file and fills in
|
|
// its token, login, and instance URL.
|
|
func (c *Config) resolvePersonalities() error {
|
|
for i := range c.Personalities {
|
|
p := &c.Personalities[i]
|
|
if p.TeaConfig == "" {
|
|
return fmt.Errorf("personality %q: tea_config is required", p.Name)
|
|
}
|
|
login, err := ParseTeaConfig(p.TeaConfig, c.GiteaURL)
|
|
if err != nil {
|
|
return fmt.Errorf("personality %q: %w", p.Name, err)
|
|
}
|
|
p.Login = login.User
|
|
p.Token = login.Token
|
|
p.URL = login.URL
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Validate checks the config is internally consistent and usable.
|
|
func (c *Config) Validate() error {
|
|
if len(c.Repos) == 0 {
|
|
return fmt.Errorf("no repos configured")
|
|
}
|
|
for _, r := range c.Repos {
|
|
if !strings.Contains(strings.Trim(r, "/"), "/") {
|
|
return fmt.Errorf("repo %q must be in owner/name form", r)
|
|
}
|
|
}
|
|
if len(c.Personalities) == 0 {
|
|
return fmt.Errorf("at least one personality is required")
|
|
}
|
|
seen := map[string]bool{}
|
|
var haveImpl, haveReview bool
|
|
for _, p := range c.Personalities {
|
|
if p.Name == "" {
|
|
return fmt.Errorf("personality with empty name")
|
|
}
|
|
if seen[p.Name] {
|
|
return fmt.Errorf("duplicate personality name %q", p.Name)
|
|
}
|
|
seen[p.Name] = true
|
|
switch p.Role {
|
|
case RoleImplementer, RoleReviewer, RoleBoth:
|
|
default:
|
|
return fmt.Errorf("personality %q: invalid role %q", p.Name, p.Role)
|
|
}
|
|
if p.Token == "" {
|
|
return fmt.Errorf("personality %q: no token found in tea config", p.Name)
|
|
}
|
|
if p.Login == "" {
|
|
return fmt.Errorf("personality %q: no username found in tea config", p.Name)
|
|
}
|
|
haveImpl = haveImpl || p.CanImplement()
|
|
haveReview = haveReview || p.CanReview()
|
|
}
|
|
if !haveImpl {
|
|
return fmt.Errorf("no personality can implement (role implementer or both)")
|
|
}
|
|
if !haveReview {
|
|
return fmt.Errorf("no personality can review (role reviewer or both)")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// BotLogins returns the set of Gitea usernames belonging to configured
|
|
// personalities, used to skip events the bot authored itself.
|
|
func (c *Config) BotLogins() map[string]bool {
|
|
m := make(map[string]bool, len(c.Personalities))
|
|
for _, p := range c.Personalities {
|
|
if p.Login != "" {
|
|
m[p.Login] = true
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// AllowedAuthorsFor returns the effective author allowlist for a repo: the
|
|
// per-repo override when one is configured (even if empty), otherwise the
|
|
// global list.
|
|
func (c *Config) AllowedAuthorsFor(repo string) []string {
|
|
if c.RepoAllowedAuthors != nil {
|
|
if v, ok := c.RepoAllowedAuthors[repo]; ok {
|
|
return v
|
|
}
|
|
}
|
|
return c.AllowedAuthors
|
|
}
|
|
|
|
// IsAuthorAllowed reports whether login may trigger a job in repo. Matching is
|
|
// case-insensitive (Gitea usernames are unique case-insensitively). An empty
|
|
// effective allowlist denies everyone (fail-closed).
|
|
func (c *Config) IsAuthorAllowed(repo, login string) bool {
|
|
if login == "" {
|
|
return false
|
|
}
|
|
for _, a := range c.AllowedAuthorsFor(repo) {
|
|
if strings.EqualFold(strings.TrimSpace(a), login) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// HasAnyAllowlist reports whether any allowlist entry is configured anywhere.
|
|
// When false, teabot dispatches nothing (fail-closed) and warns at startup.
|
|
func (c *Config) HasAnyAllowlist() bool {
|
|
if len(c.AllowedAuthors) > 0 {
|
|
return true
|
|
}
|
|
for _, v := range c.RepoAllowedAuthors {
|
|
if len(v) > 0 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ImplementerFor returns the personality that should handle issue work, or nil.
|
|
func (c *Config) ImplementerFor() *Personality {
|
|
for i := range c.Personalities {
|
|
if c.Personalities[i].CanImplement() {
|
|
return &c.Personalities[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReviewerFor returns the personality that should handle PR work, or nil.
|
|
func (c *Config) ReviewerFor() *Personality {
|
|
for i := range c.Personalities {
|
|
if c.Personalities[i].CanReview() {
|
|
return &c.Personalities[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// expandHome expands a leading ~/ to the user's home directory.
|
|
func expandHome(p string) string {
|
|
if p == "~" || strings.HasPrefix(p, "~/") {
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
if p == "~" {
|
|
return home
|
|
}
|
|
return filepath.Join(home, p[2:])
|
|
}
|
|
}
|
|
return p
|
|
}
|