Files
teabot/docs/architecture.md
unkinben 748048be50
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Add fail-closed author allowlist gating job dispatch
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
2026-07-27 00:33:09 +10:00

4.4 KiB

Architecture

teabot is a single binary with a small set of internal packages. The daemon polls Gitea, decides which events warrant work, and runs each unit of work as a disposable Docker container.

main.go
  └── internal/cli        cobra command tree (run, config)
        └── internal/dispatch   poll loop, event filtering, job orchestration
              ├── internal/gitea    read-only Gitea REST client
              ├── internal/state    processed-event persistence (JSON)
              ├── internal/prompt   per-event prompt construction
              ├── internal/config   config + tea-config parsing
              └── internal/docker   containerised Claude execution (Runner iface)

Poll cycle

dispatch.Dispatcher runs one pollRepo per watched repo each interval:

  1. List open issues, open pull requests, and recent comments via the Gitea API (one client authenticated as the first personality — reads only).
  2. Seed on first contact. The very first poll of a repo records everything currently open/recent as processed without dispatching anything, so a fresh install does not stampede every existing item. The seeded flag is persisted per repo.
  3. Classify & dispatch subsequent events:
    • a new issue → implementer session,
    • a new pull request → reviewer session,
    • a new comment on a thread teabot acted on → follow-up session.

Filtering rules (loop prevention + dedup)

Three independent guards decide whether an event becomes a job:

  • Loop prevention — any event authored by one of teabot's own personality logins is skipped. This is what stops the bot reacting to its own PRs and comments in an infinite loop. Bot-authored items are still recorded as processed so they are never reconsidered.
  • Author allowlist (security) — because jobs run --dangerously-skip-permissions with a prompt built from event text, teabot dispatches only for events whose author is on the allowed_authors list (with an optional per-repo override). This is fail-closed: an empty allowlist dispatches nothing. Non-allowlisted events are recorded (never re-triggered) and logged, but never spawn a container. Comment follow-ups are gated on the new comment's author, so an untrusted comment on a bot thread cannot reopen the injection path. See configuration.
  • Dedup — every dispatched issue/PR index and every seen comment ID is recorded in the state store (~/.local/state/teabot/state.json). An item is marked processed before its job starts, so a subsequent poll (or a restart mid-job) cannot double-launch it.

Comment follow-ups additionally require the parent issue/PR to be in the acted-on set — teabot only continues threads it started, never arbitrary comment threads. See configuration for the implication.

Job execution

Each session is a docker.Job handed to a docker.Runner. The production DockerRunner:

  1. Materialises a per-job scratch dir containing the prompt, a static job.sh entrypoint, a private copy of the Claude config dir (so subscription-token refreshes never mutate the host's ~/.claude), and a copy of the personality's tea config.
  2. Runs docker run --rm --entrypoint /bin/bash <image> /teabot/job.sh with:
    • the scratch files bind-mounted (:ro,z under SELinux),
    • the tea config mounted at $HOME/.config/tea/config.yml and XDG_CONFIG_HOME set, so tea acts as the bot identity,
    • git identity + a credential-store token so clones and pushes authenticate,
    • ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL injected only when configured (otherwise the mounted subscription credentials are used).
  3. job.sh configures git, clones the repo, and runs claude --print --dangerously-skip-permissions < /teabot/prompt.txt.

The Runner interface means the whole dispatch layer is unit-testable with a fake runner — no Docker daemon required. Concurrency is bounded by a semaphore sized to max_concurrent, and every job has a job_timeout.

Container image

teabot reuses the existing git.unkin.net/unkin/agent-dev:latest image, which already bundles the claude CLI plus a Go/Node/Python/tea developer toolchain and language servers. The image is configurable via job_image, so a purpose-built image can be substituted without code changes.