// 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 }