Add teabot daemon implementation
teabot watches Gitea repos and dispatches one-shot Claude Code sessions in Docker containers to work issues and review PRs, acting as configurable bot personalities. Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// jobScript is the entrypoint executed inside the container. It configures the
|
||||
// git identity and credentials, clones the target repo, and runs Claude in
|
||||
// non-interactive print mode reading the prompt from a mounted file. All inputs
|
||||
// arrive via environment variables so the script itself is static.
|
||||
const jobScript = `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
export HOME="${TEABOT_HOME}"
|
||||
mkdir -p "$HOME/.config"
|
||||
|
||||
git config --global user.name "${TEABOT_GIT_NAME}"
|
||||
git config --global user.email "${TEABOT_GIT_EMAIL}"
|
||||
git config --global credential.helper store
|
||||
git config --global init.defaultBranch main
|
||||
umask 077
|
||||
printf 'https://%s:%s@%s\n' "${TEABOT_GIT_USER}" "${TEABOT_TOKEN}" "${TEABOT_GIT_HOST}" > "$HOME/.git-credentials"
|
||||
|
||||
WORK="$HOME/work"
|
||||
mkdir -p "$WORK"
|
||||
cd "$WORK"
|
||||
echo "teabot: cloning ${TEABOT_CLONE_URL}"
|
||||
git clone --quiet "${TEABOT_CLONE_URL}" repo
|
||||
cd repo
|
||||
|
||||
echo "teabot: starting claude session"
|
||||
claude --print --dangerously-skip-permissions < /teabot/prompt.txt
|
||||
`
|
||||
|
||||
// DockerRunner runs jobs with the local docker CLI.
|
||||
type DockerRunner struct {
|
||||
// DockerPath is the docker binary (default "docker").
|
||||
DockerPath string
|
||||
// SELinuxLabel is the volume relabel suffix. On Fedora this must be "z"
|
||||
// (shared) or "Z" (private) so bind mounts are accessible under SELinux.
|
||||
SELinuxLabel string
|
||||
// WorkRoot is where per-job scratch directories are created
|
||||
// (default os.TempDir()).
|
||||
WorkRoot string
|
||||
// Stdout receives streamed container output (nil discards the stream; the
|
||||
// captured output is always returned in Result regardless).
|
||||
Stdout io.Writer
|
||||
}
|
||||
|
||||
// NewDockerRunner builds a runner with sensible defaults for this host.
|
||||
func NewDockerRunner() *DockerRunner {
|
||||
return &DockerRunner{DockerPath: "docker", SELinuxLabel: "z"}
|
||||
}
|
||||
|
||||
// Run implements Runner.
|
||||
func (r *DockerRunner) Run(ctx context.Context, job Job) (Result, error) {
|
||||
if job.Image == "" {
|
||||
return Result{}, errors.New("job image is empty")
|
||||
}
|
||||
jobDir, err := r.prepareJobDir(job)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
defer os.RemoveAll(jobDir)
|
||||
|
||||
if job.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, job.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
args := r.buildArgs(job, jobDir)
|
||||
start := time.Now()
|
||||
cmd := exec.CommandContext(ctx, r.DockerPath, args...)
|
||||
var buf bytes.Buffer
|
||||
if r.Stdout != nil {
|
||||
cmd.Stdout = io.MultiWriter(&buf, r.Stdout)
|
||||
cmd.Stderr = io.MultiWriter(&buf, r.Stdout)
|
||||
} else {
|
||||
cmd.Stdout = &buf
|
||||
cmd.Stderr = &buf
|
||||
}
|
||||
|
||||
runErr := cmd.Run()
|
||||
res := Result{Output: buf.String(), Duration: time.Since(start)}
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
res.TimedOut = true
|
||||
res.ExitCode = -1
|
||||
return res, fmt.Errorf("job %q timed out after %s", job.Label, job.Timeout)
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(runErr, &exitErr) {
|
||||
res.ExitCode = exitErr.ExitCode()
|
||||
return res, nil
|
||||
}
|
||||
if runErr != nil {
|
||||
return res, fmt.Errorf("running docker: %w", runErr)
|
||||
}
|
||||
res.ExitCode = 0
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// prepareJobDir materialises the mounted files for a job: the prompt, the job
|
||||
// script, a private copy of the Claude config (so token refreshes never touch
|
||||
// the host's real config), and a copy of the personality's tea config.
|
||||
func (r *DockerRunner) prepareJobDir(job Job) (string, error) {
|
||||
root := r.WorkRoot
|
||||
if root == "" {
|
||||
root = os.TempDir()
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
jobDir, err := os.MkdirTemp(root, "teabot-job-")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(jobDir, "prompt.txt"), []byte(job.Prompt), 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(jobDir, "job.sh"), []byte(jobScript), 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Copy the Claude config dir so the container can refresh subscription
|
||||
// tokens without mutating the host's ~/.claude.
|
||||
if job.ClaudeConfigDir != "" {
|
||||
if _, statErr := os.Stat(job.ClaudeConfigDir); statErr == nil {
|
||||
if err := copyTree(job.ClaudeConfigDir, filepath.Join(jobDir, "claude")); err != nil {
|
||||
return "", fmt.Errorf("copying claude config: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Copy the personality's tea config to the mounted XDG location.
|
||||
if job.TeaConfigPath != "" {
|
||||
teaDir := filepath.Join(jobDir, "tea")
|
||||
if err := os.MkdirAll(teaDir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := copyFile(job.TeaConfigPath, filepath.Join(teaDir, "config.yml")); err != nil {
|
||||
return "", fmt.Errorf("copying tea config: %w", err)
|
||||
}
|
||||
}
|
||||
return jobDir, nil
|
||||
}
|
||||
|
||||
// buildArgs assembles the full `docker run` argument list for a job. It is pure
|
||||
// (given jobDir) so it can be unit-tested without invoking docker.
|
||||
func (r *DockerRunner) buildArgs(job Job, jobDir string) []string {
|
||||
home := job.ContainerHome
|
||||
if home == "" {
|
||||
home = "/home/agent"
|
||||
}
|
||||
label := r.SELinuxLabel
|
||||
mount := func(host, container string, ro bool) string {
|
||||
spec := host + ":" + container
|
||||
if ro {
|
||||
spec += ":ro"
|
||||
if label != "" {
|
||||
spec += "," + label
|
||||
}
|
||||
} else if label != "" {
|
||||
spec += ":" + label
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
args := []string{"run", "--rm", "--entrypoint", "/bin/bash"}
|
||||
|
||||
// Mount the job scratch (prompt + script) read-only.
|
||||
args = append(args, "-v", mount(filepath.Join(jobDir, "prompt.txt"), "/teabot/prompt.txt", true))
|
||||
args = append(args, "-v", mount(filepath.Join(jobDir, "job.sh"), "/teabot/job.sh", true))
|
||||
|
||||
// Mount the private Claude config copy read-write (token refresh).
|
||||
if job.ClaudeConfigDir != "" {
|
||||
args = append(args, "-v", mount(filepath.Join(jobDir, "claude"), home+"/.claude", false))
|
||||
}
|
||||
// Mount the tea config read-only at the XDG path.
|
||||
if job.TeaConfigPath != "" {
|
||||
args = append(args, "-v", mount(filepath.Join(jobDir, "tea", "config.yml"), home+"/.config/tea/config.yml", true))
|
||||
}
|
||||
|
||||
// Environment consumed by job.sh.
|
||||
env := map[string]string{
|
||||
"TEABOT_HOME": home,
|
||||
"TEABOT_GIT_NAME": job.GitName,
|
||||
"TEABOT_GIT_EMAIL": job.GitEmail,
|
||||
"TEABOT_GIT_USER": job.GitUser,
|
||||
"TEABOT_TOKEN": job.Token,
|
||||
"TEABOT_GIT_HOST": job.GitHost,
|
||||
"TEABOT_CLONE_URL": job.CloneURL,
|
||||
"XDG_CONFIG_HOME": home + "/.config",
|
||||
}
|
||||
if job.AnthropicAPIKey != "" {
|
||||
env["ANTHROPIC_API_KEY"] = job.AnthropicAPIKey
|
||||
}
|
||||
if job.AnthropicBaseURL != "" {
|
||||
env["ANTHROPIC_BASE_URL"] = job.AnthropicBaseURL
|
||||
}
|
||||
for _, k := range sortedKeys(env) {
|
||||
args = append(args, "-e", k+"="+env[k])
|
||||
}
|
||||
|
||||
args = append(args, job.Image, "/teabot/job.sh")
|
||||
return args
|
||||
}
|
||||
|
||||
// sortedKeys returns map keys in deterministic order (stable docker args ease
|
||||
// testing and logging).
|
||||
func sortedKeys(m map[string]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
// simple insertion sort avoids importing sort for a tiny map
|
||||
for i := 1; i < len(keys); i++ {
|
||||
for j := i; j > 0 && keys[j-1] > keys[j]; j-- {
|
||||
keys[j-1], keys[j] = keys[j], keys[j-1]
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyTree recursively copies a directory tree (regular files, dirs, and
|
||||
// symlink targets are dereferenced by copyFile via Open).
|
||||
func copyTree(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(target, 0o700)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil // skip sockets/devices; symlinks are followed by Walk's lstat -> handle below
|
||||
}
|
||||
return copyFile(path, target)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user