Sandboxed OpenCode pull request reviews for Forgejo
  • Go 97.6%
  • Shell 2.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
lain 92149dcdab Merge pull request #14: autonomous Forgejo agent
Replace the trusted publication harness with one disposable autonomous OpenCode process and a dedicated public-only Forgejo identity.
2026-08-30 11:03:27 +00:00
cmd feat: let autonomous agents publish Forgejo work 2026-08-30 15:02:27 +04:00
deploy feat: let autonomous agents publish Forgejo work 2026-08-30 15:02:27 +04:00
internal feat: let autonomous agents publish Forgejo work 2026-08-30 15:02:27 +04:00
scripts feat: let autonomous agents publish Forgejo work 2026-08-30 15:02:27 +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: let autonomous agents publish Forgejo work 2026-08-30 15:02:27 +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: let autonomous agents publish Forgejo work 2026-08-30 15:02:27 +04:00
review.json feat: let autonomous agents publish Forgejo work 2026-08-30 15:02:27 +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 Forgejo webhooks, 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, 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 fixes 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.

Commands

Commands are accepted only from configured trusted users or configured trusted teams on allowed public targets:

/oc review
/oc rerun
/oc summary
/oc fix
/oc fix <bounded instruction>
@opencode-agent <question>

Review and summary require a pull request. Questions and fixes may target a public issue or pull request. Each accepted webhook captures the raw authorized command and exact target metadata in one immutable queue generation. A later command supersedes queued or pre-authorization work in the same target/kind/scope. It cannot supersede launch-authorized work; that work remains the scope blocker until it reaches a terminal state, after which the newest generation may run.

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. Webhook delivery processing and enqueue use random compare-and-swap capabilities, jobs.delivery_id is unique, and generations are keyed by repository, target number, kind, and question 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, plus old webhook deliveries.

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.