Sandboxed OpenCode pull request reviews for Forgejo
  • Go 97.9%
  • Shell 2.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-31 17:35:10 +04:00
cmd feat: process webhooks from durable inbox 2026-08-31 17:35:10 +04:00
deploy feat: process webhooks from durable inbox 2026-08-31 17:35:10 +04:00
internal feat: process webhooks from durable inbox 2026-08-31 17:35:10 +04:00
scripts feat: acknowledge tasks from disposable worker 2026-08-30 22:16:09 +04:00
.dockerignore ops: add rootless Podman deployment 2026-08-03 14:54:02 +04:00
.env.example feat: let autonomous agents publish Forgejo work 2026-08-30 15:02:27 +04:00
.gitignore chore: import OpenCode Forgejo bot MVP 2026-08-03 12:28:46 +04:00
AGENTS.md feat: let autonomous agents publish Forgejo work 2026-08-30 15:02:27 +04:00
Containerfile.dispatcher ops: add rootless Podman deployment 2026-08-03 14:54:02 +04:00
Containerfile.proxy ops: add rootless Podman deployment 2026-08-03 14:54:02 +04:00
Containerfile.worker feat: acknowledge tasks from disposable worker 2026-08-30 22:16:09 +04:00
go.mod feat: isolate OpenCode review execution 2026-08-03 14:53:56 +04:00
go.sum chore: import OpenCode Forgejo bot MVP 2026-08-03 12:28:46 +04:00
README.md feat: process webhooks from durable inbox 2026-08-31 17:35:10 +04:00
review.json feat: treat authorized requests as universal tasks 2026-08-30 18:23:26 +04:00

OpenCode Forgejo Bot

A webhook dispatcher and disposable autonomous agent launcher for public Forgejo repositories.

Architecture and trust boundary

  • Dispatcher (opencode-forgejo-bot) authenticates supported Forgejo webhooks, immutably persists each delivery, and returns 202 Accepted before decoding JSON, authorizing the sender, reading Forgejo, or enqueueing work. Its single restart-safe inbox processor then authorizes trusted users or organization-team members, performs only the public target reads needed to capture repository/issue/PR/ref/SHA metadata, and owns the SQLite queue. Its Forgejo token is read-only. It never reacts, creates statuses, comments, reviews, commits, branches, pushes, or pull requests.
  • Host launcher (opencode-review-launcher) claims jobs over the private /v2 Unix-socket API, creates a private per-job environment file, and starts at most one disposable container and one top-level opencode run process for each valid lease attempt. It never launches a model correction/retry process. The one OpenCode process may use normal in-container task/subagent tools and multiple model calls. It owns the model key and a dedicated autonomous-agent Forgejo token but does not call Forgejo publication APIs itself.
  • Disposable agent starts with an empty writable /workspace. Before the model starts, its deterministic bootstrap verifies the authenticated agent and exact triggering comment and idempotently adds the bot-owned 👀 acknowledgement. The agent then uses live git and tea to verify and obtain the exact claimed public state, reads current discussion, and performs all replies, reviews, commits, pushes, and pull-request creation or updates itself. There is no checkout preparer, discussion MCP/snapshot, trusted result publisher, or structured publication correction pass.

The launcher injects the model key and dedicated Forgejo token into the disposable agent by accepted design. Repository-controlled code runs with normal development tools and passwordless in-container sudo, and can read and exfiltrate both credentials over allowed public egress. Prompt instructions are not a security boundary.

Forgejo accounts and scopes

Use separate dispatcher and agent credentials.

The agent account must be an intentionally compromisable dedicated bot:

  • not a member of any upstream organization;
  • not an upstream repository collaborator or administrator;
  • unable to push to upstream, source, base, or protected branches;
  • able to push only to its own public forks, with a same-named fork of each repository on which agent-chosen code changes are allowed;
  • token scopes exactly: public-only, write:issue, write:repository, read:user.

The dispatcher token is also restricted to public-only and has no issue or repository write scope. Give it only the reads needed by the configured authorization mode and public target capture: read:organization, read:issue, read:repository, and read:user if Forgejo requires it. An exact-repository allowlist without team authorization may not need read:organization.

Server-side branch protection and account permissions are mandatory. The emergency kill switch is to stop the launcher and revoke the agent and model tokens; branch protection remains the backstop if an agent ignores its prompt.

Requests

Requests are accepted only from configured trusted users or configured trusted teams on allowed public issues and pull requests. A trusted user starts a non-code line with an accepted bot username followed by any nonempty free-form instruction:

@opencode-agent explain the retry path
@opencode-agent review this and fix anything you find appropriate

/oc <instruction> and /opencode <instruction> remain compatibility aliases. A bare mention, /oc, or /opencode without an instruction is ignored. Request words such as review, summary, and fix are optional hints; they neither select a restricted job type nor grant extra capabilities.

Every accepted comment becomes one universal task. The dispatcher captures the bounded raw authorized request and exact target metadata immutably, and scopes the job as comment:<comment-id>, so separate comments neither supersede nor restrict one another. After launch authorization, the disposable worker verifies the agent identity, comment author/body/target, and existing reactions, then adds one idempotent bot-owned 👀 reaction before starting the model. The disposable agent decides whether the appropriate response is an answer, investigation, review, summary, code change, tested commit, fork push, pull request, or another bounded combination. It still cannot push upstream/source/base/protected branches or merge pull requests.

The agent uses deterministic markers and opencode-agent/job-<id>-g<generation> branches to reconcile retries. It must re-check authenticated identity, target state, exact refs/SHAs, fork parentage, ownership, and branch destination immediately before each mutation. The dispatcher records only completion/failure bookkeeping; it cannot prove or roll back external Forgejo side effects.

Build and verify

Requires Go 1.24+ and rootless Podman. Tests do not make paid model calls.

gofmt -w cmd internal
go mod tidy
go test ./...
go vet ./...
sh scripts/adversarial-check.sh
git diff --check

go build -o bin/opencode-forgejo-bot ./cmd/opencode-forgejo-bot
go build -o bin/opencode-review-launcher ./cmd/opencode-review-launcher
podman build --pull=never -f Containerfile.dispatcher -t localhost/opencode-dispatcher:pilot .
podman build --pull=never -f Containerfile.worker -t localhost/opencode-worker:1.18.25 .
podman build --pull=never -f Containerfile.proxy -t localhost/opencode-egress-proxy:6.10 .

See deploy/README.md for host setup, secrets, rootless Podman requirements, migration, and operational checks.

Queue and control API

SQLite uses WAL mode at BOT_DATABASE_PATH. For a supported authenticated webhook, 202 Accepted means the exact event and payload are durably recorded; it does not mean authorization or job enqueue has completed. Reuse of a delivery ID is idempotent only when both event and payload match the immutable receipt, and mismatched reuse is rejected.

One app-owned processor scans on startup, receives nonblocking wakeups after responses, and polls periodically so missed wakeups and restarts do not strand work. It processes one delivery at a time with a two-minute processing timeout protected by a three-minute claim lease. Delivery states are received, processing, queued, ignored, and failed; random compare-and-swap processing capabilities protect completion, retry, and the atomic job-insert/queued transition. Expired processing claims are recovered. Graceful shutdown releases in-flight processing immediately without consuming an attempt. Transient Forgejo/network/timeout and temporary store failures are attempted at most three times with durable 5-second then 30-second backoff; malformed payloads and permanent configuration/API/invariant failures become failed, while ordinary unsupported or ineligible commands become ignored.

jobs.delivery_id is unique, and generations are keyed by repository, target number, universal task kind, and immutable comment scope. Migration is transactional and aborts without deleting data when it finds duplicate delivery IDs, active legacy queued/running/publishing work, or a nonzero legacy publisher-cleanup obligation.

/v2/jobs/claim, /v2/jobs/launch-authorization, /v2/jobs/preflight-failure, /v2/jobs/reconciliation-pending, /v2/jobs/requeue, /v2/jobs/completion, and /v2/jobs/failure are available only on the private control socket and require a separate 32-byte control capability. A claimed row is running with started_at IS NULL; successful launch authorization sets started_at immediately before the sole process launch. Before authorization, permanent claim/deadline/prompt failures are terminally rejected and only classified transient network/runtime/authorization-transport failures may requeue. Transient attempts, including expired pre-authorization leases, are capped at three. After authorization, every process failure and lease expiry is terminal and the job is never requeued.

Authorized lease expiry and uncertain container cleanup additionally set reconciliation_pending. Such a failed row continues to occupy its scope, so a queued successor cannot launch while the original container might still exist. The database enforces at most one launch-authorized or reconciliation-pending occupant per scope. Reconciliation-pending rows are excluded from retention and are not superseded. There is intentionally no HTTP transition that clears this flag: an operator must stop the launcher and dispatcher, inspect the worker account's Podman state, remove or otherwise prove the old container is gone, back up the database, and only then clear reconciliation_pending for that specific failed job. Lease expiry alone is never proof that the process stopped.

The configured lease must be at least four minutes: a shared three-minute cleanup/bookkeeping reserve plus a one-minute minimum execution window. The launcher bounds the agent deadline by both its configured timeout and the remaining lease. Completion, authorized failure, preflight rejection, exhausted attempts, authorized expiry, and uncertain cleanup persist only dispatcher-owned fixed bookkeeping text; arbitrary launcher/model errors and model output are never persisted. Retention removes old terminal jobs only when neither autonomous reconciliation nor legacy publisher cleanup remains, and removes only terminal (queued, ignored, or failed) webhook deliveries. Pending, retryable, and processing inbox rows are preserved.

Historical SQLite publication columns remain additive for rollback compatibility. Production code does not perform legacy publication, but migration and retention inspect fix_cleanup_pending so unresolved cleanup cannot be silently discarded.

Incompatible migration from the trusted publisher design

The autonomous /v2 workflow is incompatible with the old dispatcher/launcher/publisher protocol. Deploy the dispatcher, launcher, and worker image atomically:

  1. Quiesce webhook ingress so no new commands enqueue, while the old dispatcher, launcher, and publisher remain running.
  2. Let the old launcher drain all queued/running work and let publisher recovery finish; confirm there are no active queued, running, or publishing legacy jobs.
  3. Confirm every old fix publication cleanup obligation is zero and inventory any old publisher branches/PRs that still need operator cleanup.
  4. Stop the old launcher and dispatcher. Create and verify a consistent SQLite backup with SQLite's online .backup API (or, while every writer is stopped, checkpoint WAL and preserve the database plus any -wal/-shm files). Never point rollback binaries at a partially migrated live queue.
  5. Deploy the new dispatcher, launcher, and worker together. Do not mix old and new components.
  6. Only after old cleanup is zero, disable/remove the publisher service, revoke its token/capability, and remove its socket, account/group, files, and work directory.

Rollback requires stopping the new components and restoring the consistent pre-migration SQLite backup before starting the complete old component set. It must also account for autonomous jobs that may already have produced external Forgejo side effects. The retained historical columns do not recreate the removed publisher harness.