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