- Go 99.3%
- Shell 0.7%
| cmd | ||
| deploy | ||
| internal | ||
| scripts | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| AGENTS.md | ||
| Containerfile.dispatcher | ||
| Containerfile.proxy | ||
| Containerfile.worker | ||
| go.mod | ||
| go.sum | ||
| README.md | ||
| review.json | ||
OpenCode Forgejo Bot
This service provides request-only OpenCode review for explicitly allowed public repositories and public repositories in the pleroma organization. It uses two execution boundaries:
opencode-forgejo-botis the trusted dispatcher. It verifies Forgejo webhooks, owns the Forgejo token and SQLite queue, checks PR freshness, and publishes statuses/comments. It contains no Git, Node.js, OpenCode, or process execution path.opencode-review-launcheris a host process underopencode-worker. It owns the ZAI credential and a separate local control capability, claims leased work over a group-restricted Unix socket, verifies the socket peer UID on Linux, and invokes one disposable rootless agent container per attempt. It never holds Forgejo credentials, runs Git, or stores a repository checkout/pack/patch on the host.
The agent follows the execution model of the official OpenCode GitHub App, adapted for Forgejo: it checks out the pull request inside a disposable writable runner and gives OpenCode normal coding tools. It uses OpenCode 1.18.11 with zai-coding-plan/glm-5.2 and the high model variant. Reviewer assignment and all automatic triggers are disabled. The image build statically verifies that this OpenCode binary recognizes ZHIPU_API_KEY; the check makes no model call.
Trigger Policy
Only comments created on PR conversations are accepted. Exact repositories in BOT_ALLOWED_REPOS require the sender to be in BOT_TRUSTED_USERS. Repositories owned by a public organization in BOT_ALLOWED_ORGS instead require a live Forgejo membership check against one of BOT_TRUSTED_ORG_TEAMS; the static user allowlist cannot bypass organization policy. Every target repository and both PR sources must be public, and missing teams or API failures fail closed.
/oc review/oc summarize/oc rerun@opencode-bot review@opencode-bot summarize
Forgejo 15 sends this as X-Forgejo-Event: issue_comment. If X-Forgejo-Event-Type is present, it must be pull_request_comment. The payload must have action created and a non-null issue.pull_request. Configure the API webhook event as pull_request_comment; the outgoing outer event remains issue_comment.
Security Boundary
The dispatcher exposes only GET /healthz and POST /webhooks/forgejo on BOT_PUBLIC_SOCKET. Claim/result/failure routes are on the separately provisioned BOT_CONTROL_SOCKET only and require an exact high-entropy bearer token before body parsing or queue access. Root provisions identical mode-0600 token files separately for UID 999 and UID 996; no review container receives either copy. The launcher also verifies the dispatcher socket peer UID on Linux. Caddy mounts only the public socket directory. Claims contain public clone metadata, exact base/head SHAs, and a random expiring lease. A result is rejected unless its job, current generation, lease, and head SHA all match.
Before enqueue, the dispatcher captures a bounded immutable snapshot of the current PR conversation, submitted pull reviews, and inline review threads, then refetches the PR to bind that snapshot to the queued base/head SHAs. Before every success/failure publication or terminal status, it again requires state=open, merged=false, and both current SHAs to equal the queued SHAs. Review results with findings atomically create one uniquely marked COMMENT pull review at the exact head commit with up to 10 changed-line comments and do not repeat the result as an issue comment. Findings use Greptile-style priorities: P0 for critical merge-blocking failures, P1 for high-impact defects, and P2 for medium-impact actionable defects. Findings are ordered by priority; style, polish, speculative low-severity concerns, and nits are omitted. Publication retries reconcile the unique marker; already-persisted pending reviews from an interrupted staged publication are completed without creating another review. A definitive non-retryable API rejection uses the complete canonical fallback. An ambiguous create response is polled within a bounded compensation window and fails without publishing that fallback if its outcome remains unknowable. General findings stay in the pull-review body. Summary jobs, reviews without findings, and clean inline-publication failures use the canonical issue comment so every successful job still has one durable visible result. The dispatcher refetches both SHAs after each visible publication boundary and compensates stale or failed completion by deleting the new review and restoring or deleting the canonical comment. Compensation failure is surfaced as a publication error and never reported as safely removed.
Forgejo 15.0.5's per-PR issue-comments and per-review comment endpoints intentionally have no supported page/limit parameters. The dispatcher response-bounds them and rejects more than 1000 comments, reviews, or changed files; the job snapshot applies smaller per-endpoint, aggregate count, serialized-item, and 96 KiB limits. Snapshot review listing makes at most four non-retried 2 MiB requests and samples the final pages when Forgejo reports a larger total. Webhook processing has a four-second overall budget; optional capture runs outside the publication lock under a nested two-second deadline after the required initial PR fetch. Missing, excessive, or unavailable discussion degrades to a partial or empty snapshot with truncated=true instead of blocking the review. The snapshot is awareness-only: the agent can account for human replies and avoid repeating findings, but it has no discussion write tool and the dispatcher does not append to old threads. This keeps all new output inside the existing final review transaction. Reruns replace older bot reviews only when their inline threads contain no human replies; discussed reviews remain as history. Suggested replacements are rendered as manual code blocks because Forgejo 15 has no native one-click suggestion application. Status contexts are opencode/review and opencode/summary; statuses are best-effort decoration because the durable pull review or canonical comment records the exact SHA. Status failure is logged distinctly and does not invalidate safely published output. Success means execution completed, not approval.
The in-container checkout accepts anonymous HTTPS clone URLs only when the URL host, including an explicit port, exactly matches the pathless LAUNCHER_FORGEJO_BASE_URL. Private target repositories and private PR sources are rejected before execution, so Git receives no Forgejo credential or ambient config. It follows no redirects, initializes no submodules or LFS, runs no hooks, verifies both advertised refs resolve to the queued full SHAs, checks out the exact head, and refuses review without a merge base.
Each attempt gets a fresh bounded executable /workspace tmpfs and a writable ephemeral container root filesystem. Agent memory, CPU, and workspace ceilings default to 4 GiB, 4 CPUs, and 4 GiB and can be raised within strict launcher bounds using LAUNCHER_AGENT_MEMORY_MIB, LAUNCHER_AGENT_CPUS, and LAUNCHER_WORKSPACE_TMPFS_MIB; memory swap is disabled by setting the Podman memory-swap limit equal to memory. LAUNCHER_AGENT_CGROUP_MODE=required verifies those leaf ceilings before claiming work. The explicit outer mode is available only for a single-job executor whose enclosing VM/LXC and parent service provide accepted authoritative memory, CPU, bounded-swap, and task ceilings; per-agent Podman values may be ignored in that mode. Agent identity defaults to LAUNCHER_AGENT_IDENTITY_MODE=keep-id. The explicit rootless-root compatibility mode requires outer, runs namespace UID 0 mapped to the dedicated unprivileged launcher account, and avoids storage-layer remapping on executors where keep-id cannot start the worker image. The checkout retains normal repository files, Git metadata, symlinks, and project instructions. The default node user has passwordless sudo, so untrusted agent code can already obtain namespace root to install basic operating-system packages and run project tests. No host checkout, Forgejo token, control token, device, or container-engine socket is mounted. The launcher writes only the public discussion snapshot to a random mode-0600 runtime file, mounts it read-only at /run/opencode-discussion/snapshot.json, and unlinks it after Podman exits. Per-job Z.AI credential env files use separate random mode-0600 files under the absolute, launcher-owned mode-0700 LAUNCHER_RUNTIME_DIR (normally /run/user/996 tmpfs) and are also unlinked after Podman exits.
review.json enables normal bash, file, search, edit, task, subagent, LSP, formatter, and compaction behavior while disabling session sharing. A fixed local stdio MCP exposes only bounded read tools for the immutable discussion snapshot; its child environment explicitly clears ZHIPU_API_KEY and it has no network or mutation implementation. Repository OpenCode configuration and instruction files remain available as on the official runner. The fixed command line selects variant high; OpenCode 1.18.11 rejects a top-level config variant, so image builds retain strict schema validation instead of adding it there. The launcher disables Podman logging for ephemeral containers, enforces an 8 MiB process-output budget and 60 KiB final structured-result limit, and extracts the last strictly valid versioned JSON report from normal agent narration. Review protocol v3 requires priority to be exactly p0, p1, or p2 and rejects the former severity field for new submissions. Review reports also include a required integer merge-readiness rating from 0/5 through 5/5; a P0, P1, or P2 finding caps the rating at 1/5, 3/5, or 4/5, respectively, while summary jobs omit the rating. Persisted v2 reports accepted before an upgrade are migrated only during publication recovery, including conservative priority mapping and rating capping. The summary field targets roughly 500 bytes but accepts up to a hard 2000-byte limit so a slightly verbose otherwise-valid review is published instead of discarded. Reports reject unknown or duplicate fields, unsafe paths, invalid line anchors, HTML-comment/tool-call markers, excessive fields, and more than ten findings. When no candidate validates, the bounded internal diagnostic includes the bounded rejection reason from the latest report-shaped candidate. The default gives one agent container 50 minutes inside a 55-minute job; result publication has a separate two-minute bound within the 65-minute dispatcher lease. Every agent has a random explicit name; after completion or cancellation the launcher makes an independent bounded podman rm --force --ignore call in addition to Podman's --rm behavior. Cleanup failure is terminal and prevents another attempt from starting.
The disposable boundary protects the host and Forgejo credentials, not the per-job model credential from the reviewed revision. Repository-controlled OpenCode configuration, plugins, instructions, and test commands run with ZHIPU_API_KEY, passwordless in-container sudo, and public HTTP/HTTPS egress. A malicious revision can therefore read and exfiltrate the model key, including through review output. Use a restricted, monitored, and readily rotatable key and trigger reviews only for sources where that risk is acceptable. Deployments requiring a stronger boundary must broker model access outside the agent, disable project-controlled execution, and restrict egress destinations.
Local Development
Go 1.24 or newer and Podman are expected locally.
gofmt -w cmd internal
go test ./...
go vet ./...
sh scripts/adversarial-check.sh
Build the binaries and images:
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.11 .
podman build --pull=never -f Containerfile.proxy -t localhost/opencode-egress-proxy:6.10 .
podman run --rm --pull=never localhost/opencode-worker:1.18.11 opencode --version
Run the dispatcher and launcher from separate shells and separate environment files. Never give the launcher the Forgejo token or webhook secret, and never give the dispatcher the ZAI key.
Ubuntu Deployment
See deploy/README.md. The examples target rootless Podman 4.9.3 and systemd user services on Ubuntu 24.04 using existing UID 999 opencode-dispatcher, UID 996 opencode-worker, and primary GID 987 opencode-review. No Docker or Podman socket is mounted anywhere.
Rootful Docker Caddy mounts /run/opencode-public read-only and proxies its Unix socket. It never mounts /run/opencode-control. There is no public TCP fallback.
Queue and Retention
SQLite uses WAL mode at BOT_DATABASE_PATH. Delivery processing uses a random CAS token; job enqueue and delivery completion commit together, and jobs.delivery_id is unique. Concurrent retries cannot create additional generations. Each generation persists its bounded discussion snapshot. The first accepted structured result is persisted before external publication, and a created or reconciled pull-review ID plus any canonical-comment removal intent are persisted before subsequent publication boundaries. An expired publication lease returns that report and snapshot in the next claim, so the launcher resumes publication without another model call and a later worker failure cannot replace the accepted result. BOT_CLEANUP_INTERVAL removes old terminal jobs/payloads while preserving generation counters and canonical comment identities. Expired running or publishing leases are requeued with a new lease.
For a consistent backup, stop the dispatcher or use SQLite's online .backup command and include a restore test. Webhook payloads can contain user content and should be protected like logs.
Migration and Rollback
- Stop the old all-in-one service and back up the SQLite database plus
-wal/-shmfiles while stopped. - Build the dispatcher, worker, and proxy images locally from reviewed digest-pinned base images.
- Verify existing principals/subuid/subgid ranges, provision separate socket directories, and create rootless networks/environment files from
deploy/README.md. - Start the proxy, dispatcher, then launcher. Existing tables are upgraded additively on dispatcher startup.
- Verify one trusted
/oc reviewagainst a known head SHA before broadening access.
Keep BOT_LEASE_DURATION=65m, LAUNCHER_TIMEOUT=55m, LAUNCHER_WORKER_ATTEMPTS=1, and LAUNCHER_WORKER_ATTEMPT_TIMEOUT=50m coordinated. The combined worker-attempt budget must not exceed the launcher timeout, and the dispatcher lease must retain enough additional time for checkout and result publication.
To roll back, stop the launcher first, then the dispatcher, restore the pre-migration SQLite backup and old environment, and restart the old image. Do not point an old binary at a queue containing active leased jobs or persisted structured-result publication intent.
Secret Rotation
- Forgejo token: stop/restart only the dispatcher after replacing its mode-0600 environment file.
- Control token: stop both services, atomically replace both separately owned mode-0600 copies with the same 32-byte hex token, then start the dispatcher followed by the launcher.
- Webhook secret: update Forgejo and dispatcher in a short coordinated window; failed signatures are never queued.
- ZAI key: stop the launcher, atomically replace
LAUNCHER_ZAI_API_KEY_FILEwith a mode-0600 regular file, then restart the launcher. The key is passed to Podman as exactlyZHIPU_API_KEYthrough a random short-lived env file inLAUNCHER_RUNTIME_DIR; the launcher does not log it or place it in persistent work storage.
Adversarial Verification
Run sh scripts/adversarial-check.sh. It makes no paid model call and verifies exact-checkout Git controls, disposable agent arguments, normal coding permissions, bounded credential handling, and host environment filtering. Also verify the internal network property, proxy behavior, socket mounts, and absence of engine sockets/extra devices.