- Go 95.4%
- JavaScript 2.1%
- Shell 1.6%
- HTML 0.7%
- CSS 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
A request that is a reply in an ongoing discussion, such as a quote reply to the bot, gets a comment only when the agent has something to add; plain agreement or acknowledgement becomes a +1 or 100 reaction on the triggering comment, which private claims may now also add. |
||
| 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
A webhook dispatcher and disposable autonomous agent launcher for public Forgejo repositories, with an explicit mode that also serves one configured private repository.
Architecture and trust boundary
- Dispatcher (
opencode-forgejo-bot) authenticates supported Forgejo webhooks, immutably persists each delivery, and returns202 Acceptedbefore 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/v2Unix-socket API, creates a private per-job environment file, and starts at most one disposable container and one top-levelopencode runprocess 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 lane's 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. Its image provides pinned mise plus writable node-owned mise state with default Erlang 29, Elixir 1.20, and PostgreSQL 13 tools; repository-specific tool installation and passwordless OS-package installation remain available for disposable additional setup. In therootless-rootprofile, mise installation and PostgreSQL server commands run throughsudo -u node, while root runsapt-getdirectly; this preserves mise/database ownership and the proxy environment. Before the model starts, the public-lane deterministic bootstrap verifies the authenticated agent and exact triggering comment and idempotently adds the bot-owned 👀 acknowledgement; private-single-v1 runs the same identity/trigger checks fail-closed but skips this extra mutation. The agent then uses livegitandteato verify and obtain the exact claimed state, reads current discussion, and performs only the mutations allowed by its lane. There is no checkout preparer, discussion MCP/snapshot, trusted result publisher, or structured publication correction pass.
The launcher injects the lane's 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. Public and private-repository jobs use separate Forgejo identities and tokens; each job receives only the token for its own policy. They share one model key, queue, launcher state, transcript store, and dashboard. The private token exposure and broad egress acceptance apply only to the exact configured private repository; do not add private access to either public account.
public-v1 remains the default and rejects private/internal targets. public-plus-private-v1 serves the public policy plus one exact private repository ID and canonical full name, configured in both dispatcher and launcher. Comments on that repository take the private-single-v1 policy: direct trusted users only, a separate private read token in the dispatcher, and a separate private agent identity in the launcher. Each claim carries its own policy, and the launcher narrows its config to that policy before it builds the prompt or the worker environment. The queue is permanently bound to its lane policy. A standalone private-single-v1 lane (one private repository, fresh queue) remains supported. The private policy permits remote publication only as issue or pull-request comments on that exact still-private target; formal pull-request reviews, inline review comments, and remote fork, branch, push, commit, and pull-request creation/update are forbidden. It uses authenticated credential-helper checkout without embedding credentials in clone URLs and stops publication if target/head visibility is no longer private. These prompt restrictions are defense in depth; the dedicated private account's server-side permissions and absence of write:repository are mandatory.
Forgejo accounts and scopes
Use separate dispatcher and agent credentials.
For a public-v1 lane, 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 public 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.
The private policy uses two additional Forgejo restricted-user accounts with access only to its exact private repository. Restricted-user status and sole collaboration on that repository are mandatory server-side resource fences. The dispatcher token is read-only and private-capable (read:issue, read:repository, read:user), with no organization-wide access. The agent token omits public-only and write:repository; initially it has only write:issue, read:repository, and read:user. The private agent must not own the repository, administer its settings, change visibility, transfer it, create a fork, push, or submit formal pull-request reviews. Forgejo's coarse write:issue scope can permit more than comments within the allowed repository, so exact target/artifact restrictions remain prompt policy and must be activation-tested and explicitly accepted. Do not reuse either public account or token for the private repository.
Server-side branch protection and account permissions are mandatory. The emergency kill switch is to stop the launcher and revoke its dedicated Forgejo agent tokens and model key (one model key serves public and private-repository jobs); an already-running worker may retain its launch snapshot and must be stopped during a safe rotation. 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. On the configured private repository, only directly trusted users are accepted; organization teams never authorize it. A trusted user starts a non-code line with an accepted bot username followed by any nonempty free-form instruction:
@opencode-bot explain the retry path
/oc investigate and fix the retry race
/oc <instruction> and /opencode <instruction> remain compatibility aliases unless BOT_MENTION_ONLY=true, which disables both slash forms. A bare mention, /oc, or /opencode without an instruction is ignored. Requests remain universal tasks rather than restricted legacy job kinds. One prompt-level default is selected only for pull requests: when the first instruction word after the accepted prefix is exactly review (case-insensitive), the launcher adds the greptile-review-v1 review-only guidance profile. It asks the agent to inspect the complete diff and live discussion, report only concrete introduced defects as ordered P0/P1/P2 findings, omit nits and speculative low-value concerns, and provide a capped 0/5–5/5 merge-readiness rating. Public claims publish one coherent Forgejo review with changed-line comments where appropriate; private-repository claims instead publish one coherent issue comment with concise file/line references and forbids formal or inline reviews. The profile forbids edits, commits, pushes, and pull-request creation or updates for that task; it is guidance rather than a structured-result schema. review on an ordinary issue and review words elsewhere in an instruction do not select the profile.
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. On public lanes, the disposable worker verifies the agent identity, comment author/body/target, and existing reactions after launch authorization, then adds one idempotent bot-owned 👀 reaction before starting the model; the private lane skips this mutation. Except when a lane policy or pull-request review profile narrows the task, the public 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.33 .
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.
Launcher state and operator API
Each launcher has lane-specific persistent SQLite state, a required lane-specific mode-0700 transcript directory, and a lane-specific admin Unix socket/token. The database stores runtime settings/audit plus bounded run metadata (job/generation/attempt, stable repository and target identity, selected model/variant and revisions, lifecycle timestamps/state, transcript counters/completeness/finalization, an immutable random filesystem identity, and a generic terminal reason). The random identity is never exposed by the DTO/API and prevents numeric run-ID reuse, database rollback/recreation, or an accidentally shared directory from binding a row to another database's file. The database never stores a Z.AI API key, Forgejo token, capability, prompt/body, clone URL, transcript path, or transcript content. Persisted settings win after restart. Because this operations schema was never deployed, startup creates only the final identity-bound runs schema when absent and fails closed on any pre-release runs schema; it never binds legacy numeric transcript files. On that error, stop the lane and treat the state database plus the entire lane transcript directory as one credential-sensitive reset unit: back up if needed; securely remove or replace all transcript and retention-quarantine artifacts and all database/WAL/SHM artifacts; reprovision an empty service-owned mode-0700 transcript directory; then restart. Never remove only the database. Lane socket ownership is acquired before the database is opened or startup recovery mutates it. Runs left preparing or running across launcher startup are marked observationally abandoned with reason launcher_restarted; stale draining captures are finalized incomplete once exclusive lane ownership proves no prior writer can remain. Neither transition changes dispatcher state.
Models come from internal/modelcatalog/catalog.json, the exact zai-coding-plan and opencode-go model and variant list of the pinned OpenCode registry; the worker image build fails when it drifts. Runtime settings accept only a catalog model with one of its variants or the empty variant (OpenCode's default), and each job receives only its model provider's key (ZHIPU_API_KEY or OPENCODE_API_KEY). The optional OpenCode Go key file (LAUNCHER_OPENCODE_API_KEY_FILE) may be absent until an operator sets it; a model can be selected only when its provider key exists, and the scheduler admits no claim while the selected model has no key.
The bearer-protected admin plane exposes GET /v1/runtime, PATCH /v1/runtime, write-only PUT /v1/runtime/zai-api-key and PUT /v1/runtime/opencode-api-key, POST /v1/runtime/model-test (one "reply with test" prompt against a chosen catalog model in a disposable worker container with only that provider's key and no Forgejo credential; it does not change settings and runs one at a time), newest-first GET /v1/runs, GET /v1/runs/{id}, and GET /v1/runs/{id}/events. Event responses are text/event-stream, use sequence IDs, support either after or Last-Event-ID (not both), are concurrency-bounded, and exit cleanly only after a terminal capture is finalized and its validated contiguous sequence and exact byte length reach metadata event_count/byte_count. A missing or retention-locked finalized transcript with a positive event or byte count returns 410 Gone regardless of resume position; an invalid finalized file is rejected before 200 when possible. If integrity failure becomes visible only after a live stream has committed, the server emits one fixed no-ID transcript-integrity-error SSE event and closes instead of representing clean catch-up. Launcher lifecycle cancellation promptly closes live streams, and per-stream write deadlines are cleared before HTTP keep-alive reuse. The admin socket is group-connectable mode 0660, but every request still requires its lane-specific bearer. The optional OAuth dashboard described in deploy/README.md receives only dashboard-owned capability copies, verifies Unix peer UIDs, and proxies an explicit bounded route allowlist; Caddy never receives the private admin or observer sockets directly.
Transcript files contain framed records from OpenCode stdout NDJSON only; stderr is never captured. They are mode 0600, named with the row's immutable random identity, append-only during a run, bounded to 1 MiB per raw event line and 64 MiB per run by default, and retained for 30 days (720h), no longer than the example dispatcher retention. Transcript bytes are fsynced before durable checkpoint counters or truncation are published to SQLite. A timed-out asynchronous drain remains explicitly unfinalized so SSE and retention cannot treat its current EOF as final; late writer completion publishes final counters asynchronously, while startup safely finalizes leftovers incomplete. Terminal metadata writes are retried with bounded concurrency and backoff without blocking dispatcher bookkeeping. Exact known Z.AI and Forgejo token byte sequences are redacted before persistence, including matches crossing launcher Write chunks. Transcripts remain credential-sensitive: encoded or transformed secrets, or secrets split by agent-generated transformations, cannot be guaranteed to be found. Protect transcript storage and the admin bearer token accordingly. Capture, framing, metadata, truncation, and retention failures are observational and never rerun/fail an agent or alter dispatcher bookkeeping; they mark the transcript incomplete where metadata remains writable.
Every admin request requires the lane's strict 64-hex-character bearer token. Mutations additionally require exactly one bounded X-Opencode-Operator identity. PATCH JSON carries expected_revision; key rotation JSON carries expected_credential_revision, so stale writes fail with 409, and every response uses Cache-Control: no-store. Credential files must be distinct mode-0600 files with exact, whitespace-free, distinct values; the singly-linked Z.AI file is securely reread by RuntimeAdmin and rotation revalidates aliases and values. The API key is never returned, logged, audited, or stored in SQLite. Desired concurrency may be set to zero to pause claims; lowering it never cancels running work, while raising it wakes the central scheduler. Future claims snapshot the then-current model, variant, key, and revisions. The required cgroup mode can support a deliberately configured cap up to eight, while the outer/rootless-root profile and any lane that serves a private repository are rejected unless the cap is one. Durable lane-specific key-rotation markers and the filesystem-first cleanup marker plus its SQLite defense-in-depth fence stop admissions and restarts until explicit operator reconciliation; see the deployment guide.
The launcher and every agent container run under host UID 996. Private-repository jobs share that UID, the opencode-internal network, and the egress proxy with public jobs; a concurrency cap of one keeps them from running at the same time, but this is not containment if UID 996 is compromised. Strong same-host compromise containment would require different host principals or machines.
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 remain keyed by canonical repository name, target number, universal task kind, and immutable comment scope. Migration is transactional and aborts without deleting data when it finds duplicate delivery IDs, noncanonical live or generation repository identities, live work without a stable Forgejo repository ID, conflicting active target occupants, 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. Every claim request must negotiate repository-policy-v1, the execution mode, and the exact lane repository policy before SQLite leases any job; {} and mismatched/stale launchers are rejected without consuming an attempt. 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 target, so queued work for that target cannot launch while the original container might still exist. The database enforces at most one claimed or reconciliation-pending occupant per stable Forgejo repository ID, target type/number, and execution mode, so repository renames cannot bypass serialization. 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. Dispatcher completion, authorized failure, preflight rejection, exhausted attempts, authorized expiry, and uncertain cleanup persist only dispatcher-owned fixed bookkeeping text; arbitrary launcher/model errors and transcript output are never sent to dispatcher SQLite. The launcher-local credential-sensitive transcript store is the separate observational facility described above. Dispatcher 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. Terminal historical jobs without a captured repository ID remain readable, but queued, running, or reconciliation-pending work must have a positive repository ID before migration can proceed.
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:
- Quiesce webhook ingress so no new commands enqueue, while the old dispatcher, launcher, and publisher remain running.
- Let the old launcher drain all queued/running work and let publisher recovery finish; confirm there are no active
queued,running, orpublishinglegacy jobs. - Confirm every old fix publication cleanup obligation is zero and inventory any old publisher branches/PRs that still need operator cleanup.
- Stop the old launcher and dispatcher. Create and verify a consistent SQLite backup with SQLite's online
.backupAPI (or, while every writer is stopped, checkpoint WAL and preserve the database plus any-wal/-shmfiles). Never point rollback binaries at a partially migrated live queue. - Deploy the new dispatcher, launcher, and worker together. Do not mix old and new components.
- 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.