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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// argsString joins docker args for easy substring assertions.
|
||||
func argsString(a []string) string { return strings.Join(a, " ") }
|
||||
|
||||
// hasFlagValue reports whether args contains flag immediately followed by value.
|
||||
func hasFlagValue(args []string, flag, value string) bool {
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == flag && args[i+1] == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func baseJob() Job {
|
||||
return Job{
|
||||
Label: "unkin/teabot#issue-1",
|
||||
Image: "git.unkin.net/unkin/agent-dev:latest",
|
||||
ContainerHome: "/home/agent",
|
||||
Prompt: "do the thing",
|
||||
CloneURL: "https://git.unkin.net/unkin/teabot.git",
|
||||
GitHost: "git.unkin.net",
|
||||
GitName: "Impl Bot",
|
||||
GitEmail: "impl@unkin.net",
|
||||
GitUser: "implbot",
|
||||
Token: "secret-token",
|
||||
TeaConfigPath: "/home/ben/.config/teabot/tea-impl.yml",
|
||||
ClaudeConfigDir: "/home/ben/.claude",
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildArgsCoreShape(t *testing.T) {
|
||||
r := &DockerRunner{DockerPath: "docker", SELinuxLabel: "z"}
|
||||
args := r.buildArgs(baseJob(), "/tmp/job123")
|
||||
s := argsString(args)
|
||||
|
||||
if args[0] != "run" {
|
||||
t.Errorf("first arg = %q, want run", args[0])
|
||||
}
|
||||
if !hasFlagValue(args, "--entrypoint", "/bin/bash") {
|
||||
t.Error("missing --entrypoint /bin/bash")
|
||||
}
|
||||
if !strings.Contains(s, "--rm") {
|
||||
t.Error("missing --rm")
|
||||
}
|
||||
// Image and job script must be the trailing args.
|
||||
if args[len(args)-2] != "git.unkin.net/unkin/agent-dev:latest" || args[len(args)-1] != "/teabot/job.sh" {
|
||||
t.Errorf("trailing args = %v", args[len(args)-2:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildArgsMountsWithSELinuxLabel(t *testing.T) {
|
||||
r := &DockerRunner{DockerPath: "docker", SELinuxLabel: "z"}
|
||||
args := r.buildArgs(baseJob(), "/tmp/job123")
|
||||
s := argsString(args)
|
||||
|
||||
// Prompt + job script mounted read-only with the SELinux relabel.
|
||||
if !strings.Contains(s, "/tmp/job123/prompt.txt:/teabot/prompt.txt:ro,z") {
|
||||
t.Error("prompt mount missing or wrong flags")
|
||||
}
|
||||
if !strings.Contains(s, "/tmp/job123/job.sh:/teabot/job.sh:ro,z") {
|
||||
t.Error("job.sh mount missing or wrong flags")
|
||||
}
|
||||
// Claude config copy mounted read-write with relabel.
|
||||
if !strings.Contains(s, "/tmp/job123/claude:/home/agent/.claude:z") {
|
||||
t.Error("claude mount missing or wrong flags")
|
||||
}
|
||||
// Tea config mounted read-only at the XDG path.
|
||||
if !strings.Contains(s, "/tmp/job123/tea/config.yml:/home/agent/.config/tea/config.yml:ro,z") {
|
||||
t.Error("tea config mount missing or wrong flags")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildArgsInjectsGitAndXDGEnv(t *testing.T) {
|
||||
r := &DockerRunner{SELinuxLabel: "z"}
|
||||
args := r.buildArgs(baseJob(), "/tmp/j")
|
||||
checks := map[string]string{
|
||||
"TEABOT_GIT_USER": "implbot",
|
||||
"TEABOT_TOKEN": "secret-token",
|
||||
"TEABOT_GIT_HOST": "git.unkin.net",
|
||||
"TEABOT_CLONE_URL": "https://git.unkin.net/unkin/teabot.git",
|
||||
"TEABOT_HOME": "/home/agent",
|
||||
"XDG_CONFIG_HOME": "/home/agent/.config",
|
||||
}
|
||||
for k, v := range checks {
|
||||
if !hasFlagValue(args, "-e", k+"="+v) {
|
||||
t.Errorf("missing env -e %s=%s", k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildArgsAnthropicEnvOptIn(t *testing.T) {
|
||||
r := &DockerRunner{SELinuxLabel: "z"}
|
||||
|
||||
// Without keys, no ANTHROPIC_* env should be injected.
|
||||
args := r.buildArgs(baseJob(), "/tmp/j")
|
||||
if strings.Contains(argsString(args), "ANTHROPIC_API_KEY") {
|
||||
t.Error("ANTHROPIC_API_KEY injected when unset")
|
||||
}
|
||||
|
||||
// With keys, both are injected.
|
||||
j := baseJob()
|
||||
j.AnthropicAPIKey = "sk-test"
|
||||
j.AnthropicBaseURL = "https://gw.example.com"
|
||||
args = r.buildArgs(j, "/tmp/j")
|
||||
if !hasFlagValue(args, "-e", "ANTHROPIC_API_KEY=sk-test") {
|
||||
t.Error("missing ANTHROPIC_API_KEY env")
|
||||
}
|
||||
if !hasFlagValue(args, "-e", "ANTHROPIC_BASE_URL=https://gw.example.com") {
|
||||
t.Error("missing ANTHROPIC_BASE_URL env")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildArgsOmitsClaudeMountWhenUnset(t *testing.T) {
|
||||
r := &DockerRunner{SELinuxLabel: "z"}
|
||||
j := baseJob()
|
||||
j.ClaudeConfigDir = ""
|
||||
j.TeaConfigPath = ""
|
||||
s := argsString(r.buildArgs(j, "/tmp/j"))
|
||||
if strings.Contains(s, ".claude") {
|
||||
t.Error("claude mount present despite empty ClaudeConfigDir")
|
||||
}
|
||||
if strings.Contains(s, "tea/config.yml") {
|
||||
t.Error("tea mount present despite empty TeaConfigPath")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobScriptIsBash(t *testing.T) {
|
||||
if !strings.HasPrefix(jobScript, "#!/usr/bin/env bash") {
|
||||
t.Error("job script missing bash shebang")
|
||||
}
|
||||
for _, needed := range []string{"git clone", "claude --print", "credential.helper store", "/teabot/prompt.txt"} {
|
||||
if !strings.Contains(jobScript, needed) {
|
||||
t.Errorf("job script missing %q", needed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fakeRunner demonstrates the Runner interface is satisfiable without docker.
|
||||
type fakeRunner struct{ jobs []Job }
|
||||
|
||||
func (f *fakeRunner) Run(_ context.Context, j Job) (Result, error) {
|
||||
f.jobs = append(f.jobs, j)
|
||||
return Result{ExitCode: 0, Duration: time.Millisecond}, nil
|
||||
}
|
||||
|
||||
func TestRunnerInterfaceSatisfiedByFake(t *testing.T) {
|
||||
var r Runner = &fakeRunner{}
|
||||
res, err := r.Run(context.Background(), baseJob())
|
||||
if err != nil || res.ExitCode != 0 {
|
||||
t.Fatalf("fake runner: res=%+v err=%v", res, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Package docker runs a one-shot Claude Code session inside a container. The
|
||||
// Runner interface keeps dispatch logic testable without a real Docker daemon.
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Job fully describes a single containerised Claude session.
|
||||
type Job struct {
|
||||
// Label is a short identifier used for logging and the job directory name.
|
||||
Label string
|
||||
// Image is the container image to run.
|
||||
Image string
|
||||
// ContainerHome is the home directory inside Image (mount target root).
|
||||
ContainerHome string
|
||||
// Prompt is the full instruction handed to `claude --print`.
|
||||
Prompt string
|
||||
|
||||
// CloneURL is the plain HTTPS clone URL of the repo to work in
|
||||
// (e.g. https://git.unkin.net/unkin/teabot.git). Auth is supplied via a
|
||||
// git credential store built from Token, never embedded in this URL.
|
||||
CloneURL string
|
||||
// GitHost is the host used for the credential store entry (e.g. git.unkin.net).
|
||||
GitHost string
|
||||
|
||||
// GitName / GitEmail set the container's commit identity.
|
||||
GitName string
|
||||
GitEmail string
|
||||
// GitUser is the bot's Gitea username (credential store user).
|
||||
GitUser string
|
||||
// Token is the bot's Gitea token, used for git push and (indirectly) tea.
|
||||
Token string
|
||||
|
||||
// TeaConfigPath is the host path to the personality's tea config.yml,
|
||||
// mounted so tea acts as this identity inside the container.
|
||||
TeaConfigPath string
|
||||
// ClaudeConfigDir is the host directory holding Claude Code credentials.
|
||||
ClaudeConfigDir string
|
||||
|
||||
// AnthropicAPIKey / AnthropicBaseURL, when set, are injected as env vars
|
||||
// instead of relying on the mounted subscription credentials.
|
||||
AnthropicAPIKey string
|
||||
AnthropicBaseURL string
|
||||
|
||||
// Timeout bounds the session.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Result captures the outcome of a job.
|
||||
type Result struct {
|
||||
ExitCode int
|
||||
Output string
|
||||
Duration time.Duration
|
||||
// TimedOut is true when the job was killed for exceeding Timeout.
|
||||
TimedOut bool
|
||||
}
|
||||
|
||||
// Runner executes jobs. DockerRunner is the production implementation; tests
|
||||
// substitute a fake.
|
||||
type Runner interface {
|
||||
Run(ctx context.Context, job Job) (Result, error)
|
||||
}
|
||||
Reference in New Issue
Block a user