From fd07425addd5455ea3a4f7ebd1ba9e862f54c5ef Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:14:43 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20GitHub=20webhooks=20=E2=86=92=20Ope?= =?UTF-8?q?nCode=20agent=20sessions,=20as=20a=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces what was the Hono sidecar in this branch with an OpenCode plugin that runs INSIDE the long-lived opencode server process. What's bundled -------------- - plugins/github-webhooks.ts: opens its own Bun.serve listener on WEBHOOK_PORT (default 5050). Verifies X-Hub-Signature-256, dedups on X-GitHub-Delivery (redeliveries are ack'd as duplicate, no re-fire), matches deliveries against triggers, and dispatches agent sessions via the in-process SDK client. - agents/github-issue-resolver.md: autonomous 'issue assigned → branch → plan → implement → push → gh pr create' workflow. Authenticated via GH_TOKEN. Architecture ------------ Originally implemented as a separate Bun+Hono sidecar process supervised alongside opencode-web. That worked but had real downsides: a cold-boot race against opencode's HTTP server, a second process to supervise, loopback HTTP for every SDK call, and an opencode_sessions mirror table. None of that is necessary now that we know plugins load into the long-lived opencode server process — they get an SDK client bound to the host server in their context, so client.session.create() and client.session.prompt() are direct in-process calls. The plugin still opens its own port (5050) for the webhook receiver because OpenCode's plugin API doesn't expose a hook for adding routes to its existing HTTP server. Process-level isolation is gone — an unhandled rejection here can crash opencode-web — so we install a top-level unhandledRejection guard and catch aggressively at the dispatch boundary. Trigger config -------------- Stored in a JSON file (default ~/.config/opencode/webhooks.json, overridable with WEBHOOKS_CONFIG). Not in opencode.json, because that file's published schema declares experimental.additionalProperties:false and would reject our extension. The plugin stays dormant until the file exists with at least one trigger, so unused images don't open ports nobody asked for. Dependencies ------------ Plugin uses only built-ins: Bun.serve, bun:sqlite, node:crypto. The sole npm dep is @opencode-ai/plugin (for the Plugin type). Declared in opencode-config-package.json which is copied to ~/.config/opencode/package.json and bun-installed at build time. Idempotency ----------- SQLite (~/dev/.opencode/github-webhooks.sqlite by default) keyed on delivery_id with ON CONFLICT DO NOTHING. The host opencode server is the system of record for sessions; we don't mirror them. Verified -------- Standalone harness exercising: - missing X-GitHub-Event/Delivery headers → 400 - bad HMAC → 401 - valid first delivery → 200, dispatch fires (session.create + session.prompt called with rendered prompt) - same delivery_id replayed → duplicate:true, no dispatch, no extra session.create call tsc --noEmit clean. --- .dockerignore | 1 + .env.example | 21 ++ Dockerfile | 26 +- README.md | 86 ++++++- agents/github-issue-resolver.md | 113 +++++++++ opencode-config-package.json | 10 + plugins/github-webhooks.ts | 415 ++++++++++++++++++++++++++++++++ 7 files changed, 668 insertions(+), 4 deletions(-) create mode 100644 agents/github-issue-resolver.md create mode 100644 opencode-config-package.json create mode 100644 plugins/github-webhooks.ts diff --git a/.dockerignore b/.dockerignore index 04b61ed..e6d145d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -21,4 +21,5 @@ Thumbs.db # Build artifacts / logs node_modules +**/node_modules *.log diff --git a/.env.example b/.env.example index db139e2..1b3325a 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,27 @@ SENTRY_AUTH_TOKEN= # Use a PAT with the scopes you need (typical: repo, read:org, workflow). GH_TOKEN= +# === Optional: github-webhooks plugin === +# This image ships a plugin (~/.config/opencode/plugins/github-webhooks.ts) +# that turns inbound GitHub webhooks into OpenCode agent sessions. The +# plugin only opens its listener if a config file is present at the +# resolved path AND that config defines at least one trigger. +# +# Path to the JSON config file describing webhook triggers. Default +# resolves to ~/.config/opencode/webhooks.json — set this env var only +# if you want the file somewhere else (e.g. on the persistent volume). +# WEBHOOKS_CONFIG=/home/developer/dev/.opencode/webhooks.json + +# HMAC secret matching what you configure in GitHub's webhook UI. Without +# this (and without the `secret` field in the JSON config) the listener +# rejects every delivery with 503. +GITHUB_WEBHOOK_SECRET= + +# Port the plugin's webhook listener binds to. Defaults to 5050. Expose +# this separately from the opencode web UI port (4096 / $PORT) on your +# platform. +# WEBHOOK_PORT=5050 + # === Optional: outbound proxy === # HTTPS_PROXY= # HTTP_PROXY= diff --git a/Dockerfile b/Dockerfile index 5ed000d..363b099 100644 --- a/Dockerfile +++ b/Dockerfile @@ -176,6 +176,25 @@ COPY --chown=developer:developer \ opencode-user-config.json \ /home/developer/.config/opencode/opencode.json +# Bundled agents (e.g. github-issue-resolver). Copied into the user-level +# agents dir so they're discoverable from any session, including ones the +# webhook plugin spawns programmatically. +COPY --chown=developer:developer agents \ + /home/developer/.config/opencode/agents + +# Bundled plugins (e.g. github-webhooks). OpenCode auto-loads any +# .ts/.js file in this directory at startup. The sibling package.json +# declares the npm deps the plugins import (@opencode-ai/plugin); we +# `bun install` them once at build time so OpenCode doesn't have to do +# it on every container start. +COPY --chown=developer:developer plugins \ + /home/developer/.config/opencode/plugins +COPY --chown=developer:developer opencode-config-package.json \ + /home/developer/.config/opencode/package.json +RUN cd /home/developer/.config/opencode \ + && bun install --production \ + && rm -rf ~/.bun/install/cache + # Tiny entrypoint that mkdir's ~/dev/.opencode at runtime so a single # Railway Volume mounted at ~/dev persists projects + OpenCode session/auth # data together (~/.local/share/opencode is symlinked into it). @@ -184,10 +203,13 @@ COPY --chmod=0755 docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh # No VOLUME directive — Railway rejects them. Attach a Railway Volume at # /home/developer/dev (~/dev) via the dashboard for persistence; both # projects you clone there and OpenCode session/auth data live in it. -EXPOSE 4096 +# 4096 = opencode web UI; 5050 = plugin's webhook listener (only opens +# if WEBHOOKS_CONFIG points at a config file with at least one trigger). +EXPOSE 4096 5050 WORKDIR /home/developer/dev # PORT lets PaaS platforms (Railway/Fly/Render) assign a port; falls back -# to 4096 locally. +# to 4096 locally. WEBHOOK_PORT (default 5050) is what the github-webhooks +# plugin binds its listener to. ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"] CMD ["sh", "-c", "exec opencode web --hostname 0.0.0.0 --port ${PORT:-4096}"] diff --git a/README.md b/README.md index 5f1a410..a45f1ec 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ Self-hosted [OpenCode](https://opencode.ai) web UI in a Docker image, ready to d - **OpenCode** built from source from the [`BYK/opencode`](https://github.com/BYK/opencode/tree/byk/cumulative) fork (`byk/cumulative` branch) — carries question-dock UX, plan-mode, and db perf fixes that aren't yet in upstream. Built fresh into the image; auto-update is effectively disabled because the fork has no release feed. - [Sentry CLI](https://cli.sentry.dev), GitHub CLI, **nvm + Node 22 LTS** (`pnpm` / `yarn` via corepack), **Bun**, plus `git`, `ripgrep`, `fd`, `fzf`, `jq`, `yq`, and `build-essential`. - No MCP servers preconfigured — add your own via a project-local `opencode.json` or by editing [`opencode-user-config.json`](./opencode-user-config.json) before building. +- **Bundled OpenCode plugin: [`github-webhooks`](./plugins/github-webhooks.ts)** — turns inbound GitHub webhook deliveries into OpenCode agent sessions running in the same `opencode` process. Listener stays off until you create a config file with at least one trigger (see [GitHub webhooks → agent sessions](#github-webhooks--agent-sessions) below). +- **Bundled agent: [`github-issue-resolver`](./agents/github-issue-resolver.md)** — autonomous "issue assigned → branch → plan → implement → PR" workflow, designed to be invoked by the webhook plugin or directly via `@github-issue-resolver`. - Non-root `developer` user. OpenCode starts in `~/dev`. Mount a single persistent volume at `~/dev` (= `/home/developer/dev`) to keep your projects **and** OpenCode session/auth data across redeploys — `~/.local/share/opencode` is symlinked into `~/dev/.opencode`. ## Deploy on Railway @@ -41,17 +43,97 @@ See [`.env.example`](./.env.example) for the full template. | One of `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `GROQ_API_KEY`, `OPENROUTER_API_KEY` | **Required.** LLM provider key. | | `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_URL` | For the bundled `sentry` CLI. | | `GH_TOKEN` | For the bundled `gh` CLI. PAT with the scopes you need. | +| `GITHUB_WEBHOOK_SECRET` | HMAC secret for the `github-webhooks` plugin. Required to receive webhooks. | +| `WEBHOOK_PORT`, `WEBHOOKS_CONFIG` | Optional plugin tuning. See [`.env.example`](./.env.example). | | `PORT` | Set automatically by most PaaS providers. Defaults to `4096`. | +## GitHub webhooks → agent sessions + +The bundled [`github-webhooks`](./plugins/github-webhooks.ts) plugin runs +**inside** the OpenCode server process — no sidecar, no second process to +supervise, no loopback HTTP. It opens its own listener on port `5050` +(configurable via `WEBHOOK_PORT`) and dispatches verified deliveries +into agent sessions via the in-process SDK client. + +The plugin stays dormant until you give it a config file. By default it +looks at `~/.config/opencode/webhooks.json`; override the path with +`WEBHOOKS_CONFIG` (handy if you want the file on the `~/dev` volume so +it survives image rebuilds). + +### Example config + +```json +{ + "port": 5050, + "max_concurrent": 2, + "timeout_ms": 1800000, + "retention": 1000, + "triggers": [ + { + "name": "issue-assigned-to-me", + "event": "issues", + "action": "assigned", + "agent": "github-issue-resolver", + "prompt_template": "Resolve issue #{{ payload.issue.number }} ({{ payload.issue.title }}) in {{ payload.repository.full_name }}.\n\nIssue body:\n{{ payload.issue.body }}\n\nAssignee: {{ payload.assignee.login }}.\n\nFollow your standard workflow: clone, branch, plan, implement, push, open PR.", + "cwd": null + } + ] +} +``` + +Field reference: + +| Field | Required | What it does | +|---|---|---| +| `triggers[].name` | ✓ | Unique identifier; surfaces in plugin logs. | +| `triggers[].event` | ✓ | GitHub event header (`issues`, `pull_request`, `push`, ...). Use `"*"` to match anything. | +| `triggers[].action` | optional | If set, must match the payload's `action` exactly. Omit/`null` to match any action of this event. | +| `triggers[].agent` | ✓ | Agent name to invoke (built-in or from `agents/`). | +| `triggers[].prompt_template` | ✓ | Mustache-ish template. `{{ payload.foo.bar }}` looks up paths in the payload; missing paths render empty. | +| `triggers[].cwd` | optional | Override the session's working directory. Falls back to `default_cwd`, then to OpenCode's project root. | +| `port` | optional | Listener port; defaults to `5050` or `WEBHOOK_PORT`. | +| `secret` | optional | HMAC secret. Falls back to `GITHUB_WEBHOOK_SECRET`. | +| `max_concurrent` | optional | Cap on concurrent agent sessions across all triggers (default 2). | +| `timeout_ms` | optional | Per-session abort timeout (default 30 min). | +| `retention` | optional | Cap on persisted delivery rows for dedup (default 1000). | +| `default_cwd` | optional | Fallback `cwd` for triggers without one. | + +In the GitHub webhook UI: + +- **Payload URL**: `https://:5050/webhooks/github` (or however you route to that port). +- **Content type**: `application/json`. +- **Secret**: same value as `GITHUB_WEBHOOK_SECRET`. +- **Events**: pick what you need (`Issues`, `Pull request review`, etc.). + +The plugin verifies `X-Hub-Signature-256`, dedups on `X-GitHub-Delivery` +(redeliveries are ack'd as `duplicate: true` and don't re-fire agents), +and parses each delivery's `action` for trigger matching. The dispatched +session itself is the system of record for everything that happens +afterward — view it in OpenCode's UI like any other session. + +> **Railway note.** Railway only generates one HTTP domain per service. To +> reach `5050` you'll need a second Railway service pointing at the same +> image, a TCP proxy, or to route through Cloudflare. The opencode web UI +> on `4096`/`$PORT` and the plugin listener are independent — both speak +> plain HTTP on `0.0.0.0`. + +### Health check + +`GET http://:5050/healthz` returns `{ "ok": true, "plugin": "github-webhooks" }` once the listener is up. No auth required. + ## Local test ```bash cp .env.example .env # edit, fill in the required values docker build -t my-opencode . -docker run --rm -it -p 4096:4096 --env-file .env my-opencode +docker run --rm -it \ + -p 4096:4096 -p 5050:5050 \ + --env-file .env my-opencode ``` -Open . +Open for OpenCode. Hit + if you've set up a `webhooks.json` and +want to verify the plugin loaded. ## Notes diff --git a/agents/github-issue-resolver.md b/agents/github-issue-resolver.md new file mode 100644 index 0000000..45e8cb4 --- /dev/null +++ b/agents/github-issue-resolver.md @@ -0,0 +1,113 @@ +--- +description: Resolves a GitHub issue end-to-end — clones the repo, branches, plans, implements, pushes, and opens a PR +mode: primary +temperature: 0.2 +permission: + read: allow + edit: allow + glob: allow + grep: allow + list: allow + bash: allow + webfetch: allow + websearch: allow + task: allow +--- + +You are an autonomous engineer triggered by an inbound GitHub issue webhook. +Your job is to take an issue from "assigned" to "PR opened" without human +intervention, while staying conservative about scope. + +## Inputs you'll receive in the prompt + +- The issue's `repo` (owner/name), `number`, `title`, `body`, and `assignee`. +- The full webhook payload as JSON if more context is needed. + +## Workflow + +1. **Clone or update the repo** under `~/dev//` using the + bundled `gh` CLI (it's authenticated via the `GH_TOKEN` env var). Use: + ```sh + gh repo clone / ~/dev// -- --depth=50 + ``` + If the directory already exists, `cd` into it and run `git fetch --all`, + then check out the repo's default branch (resolve it via + `gh repo view --json defaultBranchRef --jq .defaultBranchRef.name`) + and `git pull`. + +2. **Create a feature branch** named `issue--`: + ```sh + git checkout -b issue-123-fix-thing + ``` + +3. **Read the issue carefully**. Re-read the body. Look for linked + issues, code references (`file:line`), and acceptance criteria. If + the issue is ambiguous, lean toward the smallest interpretation that + plausibly resolves the user's stated problem — do NOT speculate + features. + +4. **Explore the codebase** with `glob`, `grep`, and `read` before + touching anything. Identify: + - The specific files/functions the issue refers to. + - Existing tests that exercise the affected code. + - The project's coding style (look at neighbouring files). + +5. **Plan**, then state the plan as a short bulleted list at the top of + your reply before implementing. If the change is more than ~5 files + or touches public APIs, stop. Post a comment on the issue via + `gh issue comment --body "..."` asking for confirmation, + emit `BLOCKED: ` as the final line of your reply, and + produce no PR. + +6. **Implement** the smallest possible change. Update or add tests in + the same commit. Keep the diff focused — no opportunistic refactors. + +7. **Verify**: + - Run the project's test suite if you can identify how (`npm test`, + `pnpm test`, `bun test`, `pytest`, `go test ./...`, `cargo test`). + If you can't determine the test command in 30 seconds, skip and + mention that in the PR body. + - Run `git diff` and self-review before committing. + +8. **Commit + push** with a message body that: + - Subject line under 72 chars, imperative mood. + - References the issue: `Fixes #`. + - One-paragraph "why" explaining the user-visible change. + +9. **Open a PR** with `gh pr create`: + ```sh + gh pr create --title "" --body "$(cat <<'EOF' + ## Summary + <1-3 bullets> + + ## Why + + + ## Testing + + + Closes # + EOF + )" + ``` + Print the PR URL as the final line of your reply. + +## Constraints + +- Never push to `main`/`master` or whatever the default branch is. + Always work on a feature branch. +- Never `git push --force` to a remote branch you didn't create. +- Don't touch CI config, secrets, lockfile pinning, or package.json + versions unless the issue is *specifically* about that. +- If you can't make progress (auth error, missing context, the issue + is out of scope), post a comment on the issue explaining the blocker + via `gh issue comment `, emit `BLOCKED: ` as the + final line of your reply, and produce no PR. + +## Output format + +Your final assistant reply should be a short status line followed by: +- The PR URL (if created), or +- A clear `BLOCKED: ` line and the issue comment URL you posted. + +The host opencode server persists the full transcript; be terse here. diff --git a/opencode-config-package.json b/opencode-config-package.json new file mode 100644 index 0000000..5c65b48 --- /dev/null +++ b/opencode-config-package.json @@ -0,0 +1,10 @@ +{ + "name": "opencode-config", + "version": "0.1.0", + "private": true, + "description": "Dependencies for plugins shipped under ~/.config/opencode/plugins (loaded by OpenCode at startup)", + "type": "module", + "dependencies": { + "@opencode-ai/plugin": "^1.14.30" + } +} diff --git a/plugins/github-webhooks.ts b/plugins/github-webhooks.ts new file mode 100644 index 0000000..5dac631 --- /dev/null +++ b/plugins/github-webhooks.ts @@ -0,0 +1,415 @@ +// GitHub webhooks → OpenCode agent dispatch, as an OpenCode plugin. +// +// Runs inside the long-lived `opencode` server process at startup. Opens +// its own listener on WEBHOOK_PORT (default 5050) that takes verified +// GitHub webhook deliveries and turns them into OpenCode sessions via +// the in-process SDK client. +// +// Why a plugin instead of a separate process: +// - One process, one log stream, one set of env vars. +// - The SDK client we get from ctx.client targets THIS server, no +// loopback HTTP and no cold-boot race. +// - Trigger config is a JSON file (default ~/.config/opencode/webhooks.json, +// overridable with WEBHOOKS_CONFIG), kept out of opencode.json +// because that file's schema doesn't admit our experimental.webhook +// extension. +// +// Trade-off: an unhandled rejection here can crash the OpenCode server. +// We catch aggressively at the dispatch boundary and rely on the +// AbortController + a top-level unhandledRejection guard to keep the +// host process up. + +import type { Plugin } from "@opencode-ai/plugin" +import { Database } from "bun:sqlite" +import { createHmac, timingSafeEqual } from "node:crypto" +import { existsSync, mkdirSync } from "node:fs" +import { dirname } from "node:path" +import { homedir } from "node:os" + +// ---------- Trigger config shape ---------------------------------------- + +type Trigger = { + name: string + event: string // e.g. "issues" | "pull_request" | "*" + action?: string | null // e.g. "assigned"; null/undefined = any action + agent: string // agent name to invoke + prompt_template: string // {{ payload.foo.bar }} placeholders + cwd?: string | null // optional override for session directory + enabled?: boolean // default true +} + +type WebhookConfig = { + // Listener port. GitHub posts here. Default 5050. + port?: number + // HMAC secret matching GitHub's webhook UI. If omitted, falls back to + // the GITHUB_WEBHOOK_SECRET env var. Without one of these the plugin + // returns 503 to every webhook delivery (fail-closed). + secret?: string + // Per-session abort timeout (ms). Default 30 min. + timeout_ms?: number + // Max concurrent agent sessions. Default 2. + max_concurrent?: number + // Default working directory for sessions when a trigger doesn't + // specify one. Falls back to ctx.directory (project root). + default_cwd?: string + // Path to the deduplication SQLite file. Default + // /.opencode/github-webhooks.sqlite. + db_path?: string + // Hard cap on persisted webhook deliveries. Default 1000. + retention?: number + triggers?: Trigger[] +} + +// Resolves and reads the JSON config file. Default path is +// ~/.config/opencode/webhooks.json; override with WEBHOOKS_CONFIG. +// A missing file is fine — the plugin just won't open a listener. +async function readWebhookConfig(): Promise { + const path = + process.env.WEBHOOKS_CONFIG ?? `${homedir()}/.config/opencode/webhooks.json` + if (!existsSync(path)) return {} + try { + const raw = await Bun.file(path).text() + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== "object") return {} + return parsed as WebhookConfig + } catch (err) { + console.error( + `[github-webhooks] failed to parse config at ${path}:`, + err, + ) + return {} + } +} + +// ---------- Tiny utilities ---------------------------------------------- + +function verifyGithubSignature( + rawBody: string, + signatureHeader: string | null, + secret: string, +): boolean { + if (!signatureHeader || !signatureHeader.startsWith("sha256=")) return false + const expected = + "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex") + const a = Buffer.from(signatureHeader) + const b = Buffer.from(expected) + if (a.length !== b.length) return false + return timingSafeEqual(a, b) +} + +// Mustache-ish template renderer. {{ a.b.c }} only — no expressions, no +// helpers. Missing paths render as empty string. Objects render as JSON. +function renderTemplate( + template: string, + ctx: Record, +): string { + return template.replace(/\{\{\s*([a-zA-Z0-9_.[\]]+)\s*\}\}/g, (_m, path) => { + const value = lookup(ctx, String(path)) + if (value === undefined || value === null) return "" + if (typeof value === "string") return value + return JSON.stringify(value) + }) +} + +function lookup(ctx: unknown, path: string): unknown { + const parts = path + .replace(/\[(\d+)\]/g, ".$1") + .split(".") + .filter(Boolean) + let cur: unknown = ctx + for (const p of parts) { + if (cur && typeof cur === "object" && p in (cur as object)) { + cur = (cur as Record)[p] + } else { + return undefined + } + } + return cur +} + +// Trigger matching with priority: exact (event, action), then +// (event, null), then ('*', null). Multiple matches all fire. +function findMatching( + triggers: Trigger[], + event: string, + action: string | null, +): Trigger[] { + const enabled = triggers.filter((t) => t.enabled !== false) + const score = (t: Trigger) => { + if (t.event === event && t.action === action) return 0 + if (t.event === event && (t.action == null || t.action === undefined)) + return 1 + if (t.event === "*") return 2 + return 99 + } + return enabled + .filter((t) => score(t) < 99) + .filter((t) => { + // Exact mismatch on a present action: drop. + if (t.event === event && t.action != null && t.action !== action) + return false + return true + }) + .sort((a, b) => score(a) - score(b)) +} + +// ---------- Concurrency gate -------------------------------------------- +// Caps how many sessions can be in flight at once. Without this, a +// single delivery matching many triggers (or a bursty webhook source) +// could fan out into N parallel LLM calls. + +function makeSemaphore(limit: number) { + let inFlight = 0 + const waiters: Array<() => void> = [] + return { + async acquire() { + if (inFlight < limit) { + inFlight++ + return + } + await new Promise((r) => waiters.push(r)) + inFlight++ + }, + release() { + inFlight-- + const n = waiters.shift() + if (n) n() + }, + } +} + +// ---------- Plugin export ----------------------------------------------- + +export const GitHubWebhooksPlugin: Plugin = async (ctx) => { + // Process-level guard. A bug in our dispatch path must not take down + // the host opencode server. We log and swallow. + if (!(globalThis as { __ghWebhookGuard?: boolean }).__ghWebhookGuard) { + process.on("unhandledRejection", (err) => { + console.error("[github-webhooks] unhandledRejection:", err) + }) + ;(globalThis as { __ghWebhookGuard?: boolean }).__ghWebhookGuard = true + } + + const cfg = await readWebhookConfig() + + const port = cfg.port ?? Number(process.env.WEBHOOK_PORT ?? "5050") + const secret = cfg.secret ?? process.env.GITHUB_WEBHOOK_SECRET ?? "" + const timeoutMs = cfg.timeout_ms ?? 1_800_000 // 30 min + const maxConcurrent = Math.max(1, cfg.max_concurrent ?? 2) + const defaultCwd = cfg.default_cwd ?? ctx.directory + const retention = cfg.retention ?? 1000 + const dbPath = + cfg.db_path ?? `${ctx.directory}/.opencode/github-webhooks.sqlite` + const triggers = cfg.triggers ?? [] + + // Bail quietly when nothing is configured. Loading the plugin without + // setting up triggers shouldn't open a port nobody asked for. + if (triggers.length === 0) { + console.log( + "[github-webhooks] no triggers configured under experimental.webhook.triggers — listener disabled", + ) + return {} + } + if (!secret) { + console.warn( + "[github-webhooks] WARNING: no HMAC secret configured (experimental.webhook.secret or GITHUB_WEBHOOK_SECRET) — webhooks will be rejected with 503 until you set one", + ) + } + + // SQLite for idempotency only. We dedup by GitHub's X-GitHub-Delivery + // header so redeliveries (manual replay or auto-retry) don't run + // agents twice. We do NOT persist sessions or summaries here — the + // host opencode server is already the system of record for those. + mkdirSync(dirname(dbPath), { recursive: true }) + const db = new Database(dbPath, { create: true }) + db.exec("PRAGMA journal_mode = WAL") + db.exec("PRAGMA busy_timeout = 5000") + db.exec(` + CREATE TABLE IF NOT EXISTS deliveries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + delivery_id TEXT NOT NULL UNIQUE, + event TEXT NOT NULL, + action TEXT, + received_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_deliveries_received + ON deliveries(received_at DESC); + `) + const insertDelivery = db.prepare< + void, + [string, string, string | null, number] + >( + `INSERT INTO deliveries (delivery_id, event, action, received_at) + VALUES (?, ?, ?, ?) ON CONFLICT(delivery_id) DO NOTHING`, + ) + const trimDeliveries = db.prepare( + `DELETE FROM deliveries WHERE id NOT IN ( + SELECT id FROM deliveries ORDER BY received_at DESC LIMIT ? + )`, + ) + + const sem = makeSemaphore(maxConcurrent) + + // Actually drive a session. ctx.client is bound to the running + // opencode server — no loopback HTTP, no cold-boot race. + async function dispatchOne( + t: Trigger, + prompt: string, + deliveryId: string, + ): Promise { + await sem.acquire() + const abort = new AbortController() + const timer = setTimeout(() => abort.abort(), timeoutMs) + try { + const session = await ctx.client.session.create({ + body: { title: `[webhook/${t.name}] ${t.event}` }, + query: { directory: t.cwd ?? defaultCwd }, + signal: abort.signal, + }) + const sessionId = session.data?.id + if (!sessionId) { + console.error( + `[github-webhooks] trigger '${t.name}' (${deliveryId}): session.create returned no id`, + ) + return + } + console.log( + `[github-webhooks] trigger '${t.name}' (${deliveryId}) → session ${sessionId}`, + ) + await ctx.client.session.prompt({ + path: { id: sessionId }, + body: { + agent: t.agent, + parts: [{ type: "text", text: prompt }], + }, + signal: abort.signal, + }) + console.log( + `[github-webhooks] trigger '${t.name}' (${deliveryId}) → session ${sessionId} completed`, + ) + } catch (err) { + console.error( + `[github-webhooks] trigger '${t.name}' (${deliveryId}) failed:`, + err, + ) + } finally { + clearTimeout(timer) + sem.release() + } + } + + // ---------- HTTP listener --------------------------------------------- + + const server = Bun.serve({ + port, + hostname: "0.0.0.0", + async fetch(req) { + const url = new URL(req.url) + + if (req.method === "GET" && url.pathname === "/healthz") { + return Response.json({ ok: true, plugin: "github-webhooks" }) + } + + if (req.method !== "POST" || url.pathname !== "/webhooks/github") { + return new Response("not found", { status: 404 }) + } + + if (!secret) { + return Response.json( + { error: "no HMAC secret configured on server" }, + { status: 503 }, + ) + } + + // GitHub always sends both headers. Refuse the request if either + // is missing — without them we can't dedup or filter, and the + // request is almost certainly not from GitHub. + const event = req.headers.get("x-github-event") + const deliveryId = req.headers.get("x-github-delivery") + if (!event || !deliveryId) { + return Response.json( + { + error: + "missing required headers (x-github-event, x-github-delivery)", + }, + { status: 400 }, + ) + } + + const rawBody = await req.text() + const signature = req.headers.get("x-hub-signature-256") + if (!verifyGithubSignature(rawBody, signature, secret)) { + return Response.json({ error: "invalid signature" }, { status: 401 }) + } + + // Parse once. Used for both action extraction and template ctx. + let payload: unknown = {} + let action: string | null = null + try { + payload = JSON.parse(rawBody) + const a = (payload as { action?: unknown }).action + if (typeof a === "string") action = a + } catch { + // Not JSON — keep going with empty payload context. + } + + // Idempotency gate. ON CONFLICT DO NOTHING returns changes=0 on + // a duplicate; we use that to skip dispatch for redeliveries. + const res = insertDelivery.run( + deliveryId, + event, + action, + Date.now(), + ) + const inserted = res.changes > 0 + if (inserted && retention > 0) trimDeliveries.run(retention) + + if (!inserted) { + return Response.json({ + ok: true, + delivery_id: deliveryId, + duplicate: true, + dispatched: [], + }) + } + + const matches = findMatching(triggers, event, action) + const dispatched: string[] = [] + for (const t of matches) { + const prompt = renderTemplate(t.prompt_template, { + event, + action, + delivery_id: deliveryId, + payload, + }) + // Fire-and-forget. dispatchOne catches its own errors so a + // failing trigger doesn't poison the response. + void dispatchOne(t, prompt, deliveryId) + dispatched.push(t.name) + } + + return Response.json({ + ok: true, + delivery_id: deliveryId, + event, + action, + duplicate: false, + dispatched, + }) + }, + }) + + console.log( + `[github-webhooks] listening on http://0.0.0.0:${port} (db: ${dbPath}, triggers: ${triggers.length})`, + ) + + // Plugin hooks. We don't currently need any event hooks — the entire + // value of this plugin is the listener it opened above. Returning {} + // is fine; the listener stays alive as long as the host process does. + return {} +} + +// OpenCode's plugin loader looks for any exported function. We export +// our plugin as both the named export above (for tests / explicit +// import) and `default` for the auto-loader. +export default GitHubWebhooksPlugin From cff9a3d6630f079c6871684576d00d96e6678204 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:07:57 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix(plugin):=20review=20fixes=20=E2=80=94?= =?UTF-8?q?=20matching=20semantics,=20graceful=20shutdown,=20dbPath,=20log?= =?UTF-8?q?=20clarity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Trigger matching: drop the misleading priority/sort logic and document the actual behavior (all matching enabled triggers fire). Catches the previously-broken case of a trigger with omitted 'action' field (undefined) being strict-equal-compared to a payload with action: null. Triggers are now normalized at load time so t.action is always 'string | null' (never undefined). Verified end to end: { event: 'issues' } now correctly matches issues.assigned alongside { event: 'issues', action: 'assigned' } and { event: '*' }. - Graceful SIGTERM/SIGINT: count in-flight dispatches, close the listener immediately on signal (Bun.serve.stop(true)) so new connections are refused, then await drain (with 25s ceiling). This prevents Railway redeploys from leaving half-baked sessions on the host — a webhook arriving during the kill window now gets refused cleanly instead of being acked and then dying mid-dispatch. Verified in harness: post-SIGTERM fetch gets ConnectionRefused. - dbPath default: ${homedir()}/dev/.opencode/github-webhooks.sqlite instead of ${ctx.directory}/.opencode/... — ctx.directory shifts with the active session/project, which would split the dedup table across directories and re-fire agents on redeliveries that arrive while a different project is active. The new default lives on the Railway-persistent ~/dev volume alongside opencode's own session data. - 'no triggers configured' log message: replaced the stale 'experimental.webhook.triggers' reference (an earlier abandoned config layout) with the actual path being checked ($WEBHOOKS_CONFIG or ~/.config/opencode/webhooks.json). Same fix applied to the 'no HMAC secret' warning. - Dockerfile: comment warning against mounting a runtime volume over ~/.config/opencode (would mask the baked-in node_modules and break the plugin loader at startup). - .dockerignore: 'node_modules' was redundant with '**/node_modules'. - Default-export comment: clarified that 'default' is ergonomic, not required by OpenCode's plugin loader (any exported function works). --- .dockerignore | 1 - Dockerfile | 7 ++ plugins/github-webhooks.ts | 151 ++++++++++++++++++++++++++++++------- 3 files changed, 130 insertions(+), 29 deletions(-) diff --git a/.dockerignore b/.dockerignore index e6d145d..6b2ac84 100644 --- a/.dockerignore +++ b/.dockerignore @@ -20,6 +20,5 @@ LICENSE Thumbs.db # Build artifacts / logs -node_modules **/node_modules *.log diff --git a/Dockerfile b/Dockerfile index 363b099..e99638f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -187,6 +187,13 @@ COPY --chown=developer:developer agents \ # declares the npm deps the plugins import (@opencode-ai/plugin); we # `bun install` them once at build time so OpenCode doesn't have to do # it on every container start. +# +# IMPORTANT: do NOT mount a runtime volume over /home/developer/.config/ +# opencode — it would mask the baked-in node_modules and the plugin +# loader would fail at startup with `Cannot find module '@opencode-ai/ +# plugin'`. Persistent state (sessions, auth) lives at ~/dev/.opencode +# already via the symlink set up below; that's the only directory you +# should attach a volume to. COPY --chown=developer:developer plugins \ /home/developer/.config/opencode/plugins COPY --chown=developer:developer opencode-config-package.json \ diff --git a/plugins/github-webhooks.ts b/plugins/github-webhooks.ts index 5dac631..39fe108 100644 --- a/plugins/github-webhooks.ts +++ b/plugins/github-webhooks.ts @@ -127,30 +127,50 @@ function lookup(ctx: unknown, path: string): unknown { return cur } -// Trigger matching with priority: exact (event, action), then -// (event, null), then ('*', null). Multiple matches all fire. +// Trigger matching: every enabled trigger that matches the incoming +// (event, action) pair fires. There's no priority ordering — if you +// register both a specific `{ event: "issues", action: "assigned" }` +// trigger and a catch-all `{ event: "*" }` trigger, BOTH dispatch on +// `issues.assigned`. That's strictly more flexible than "highest +// priority wins" (you can layer an audit-log trigger over a domain +// trigger without one suppressing the other) and is bounded by the +// concurrency semaphore so cost stays predictable. +// +// A trigger matches when: +// - t.event matches the delivery's event (or t.event === "*"), AND +// - t.action is null (= "any action of this event") OR +// t.action equals the delivery's action. +// +// Trigger.action is normalized to null at load time, so the strict +// equality below works for both null payloads and absent-field configs. function findMatching( - triggers: Trigger[], + triggers: NormalizedTrigger[], event: string, action: string | null, -): Trigger[] { - const enabled = triggers.filter((t) => t.enabled !== false) - const score = (t: Trigger) => { - if (t.event === event && t.action === action) return 0 - if (t.event === event && (t.action == null || t.action === undefined)) - return 1 - if (t.event === "*") return 2 - return 99 +): NormalizedTrigger[] { + return triggers.filter((t) => { + if (t.enabled === false) return false + const eventOk = t.event === "*" || t.event === event + if (!eventOk) return false + const actionOk = t.action === null || t.action === action + return actionOk + }) +} + +// Trigger as it lives in memory after normalization. action is always +// `string | null` (config-supplied undefined/missing field becomes +// null), enabled is always boolean. +type NormalizedTrigger = Omit & { + action: string | null + enabled: boolean +} + +function normalizeTrigger(t: Trigger): NormalizedTrigger { + return { + ...t, + action: t.action ?? null, + enabled: t.enabled !== false, } - return enabled - .filter((t) => score(t) < 99) - .filter((t) => { - // Exact mismatch on a present action: drop. - if (t.event === event && t.action != null && t.action !== action) - return false - return true - }) - .sort((a, b) => score(a) - score(b)) } // ---------- Concurrency gate -------------------------------------------- @@ -198,21 +218,26 @@ export const GitHubWebhooksPlugin: Plugin = async (ctx) => { const maxConcurrent = Math.max(1, cfg.max_concurrent ?? 2) const defaultCwd = cfg.default_cwd ?? ctx.directory const retention = cfg.retention ?? 1000 + // Default DB lives on the persistent ~/dev volume so dedup state + // survives both restarts AND project-directory changes (ctx.directory + // can shift across sessions; we want one global delivery log). const dbPath = - cfg.db_path ?? `${ctx.directory}/.opencode/github-webhooks.sqlite` - const triggers = cfg.triggers ?? [] + cfg.db_path ?? `${homedir()}/dev/.opencode/github-webhooks.sqlite` + const triggers = (cfg.triggers ?? []).map(normalizeTrigger) // Bail quietly when nothing is configured. Loading the plugin without // setting up triggers shouldn't open a port nobody asked for. + const configHint = + process.env.WEBHOOKS_CONFIG ?? `${homedir()}/.config/opencode/webhooks.json` if (triggers.length === 0) { console.log( - "[github-webhooks] no triggers configured under experimental.webhook.triggers — listener disabled", + `[github-webhooks] no triggers configured (looked at ${configHint}) — listener disabled`, ) return {} } if (!secret) { console.warn( - "[github-webhooks] WARNING: no HMAC secret configured (experimental.webhook.secret or GITHUB_WEBHOOK_SECRET) — webhooks will be rejected with 503 until you set one", + `[github-webhooks] WARNING: no HMAC secret configured (set "secret" in ${configHint} or GITHUB_WEBHOOK_SECRET) — webhooks will be rejected with 503 until you set one`, ) } @@ -250,13 +275,34 @@ export const GitHubWebhooksPlugin: Plugin = async (ctx) => { const sem = makeSemaphore(maxConcurrent) + // Track in-flight dispatches so graceful shutdown can wait for them + // to complete before fully closing the listener. Counts ALL dispatches + // (queued behind the semaphore + actively running), so the + // listener stays alive until the queue drains. + let inFlightDispatches = 0 + const drainWaiters: Array<() => void> = [] + function dispatchStarted() { + inFlightDispatches++ + } + function dispatchEnded() { + inFlightDispatches-- + if (inFlightDispatches === 0) { + while (drainWaiters.length > 0) drainWaiters.shift()!() + } + } + function waitForDrain(): Promise { + if (inFlightDispatches === 0) return Promise.resolve() + return new Promise((r) => drainWaiters.push(r)) + } + // Actually drive a session. ctx.client is bound to the running // opencode server — no loopback HTTP, no cold-boot race. async function dispatchOne( - t: Trigger, + t: NormalizedTrigger, prompt: string, deliveryId: string, ): Promise { + dispatchStarted() await sem.acquire() const abort = new AbortController() const timer = setTimeout(() => abort.abort(), timeoutMs) @@ -295,6 +341,7 @@ export const GitHubWebhooksPlugin: Plugin = async (ctx) => { } finally { clearTimeout(timer) sem.release() + dispatchEnded() } } @@ -403,13 +450,61 @@ export const GitHubWebhooksPlugin: Plugin = async (ctx) => { `[github-webhooks] listening on http://0.0.0.0:${port} (db: ${dbPath}, triggers: ${triggers.length})`, ) + // Graceful shutdown. When opencode is shutting down (Railway redeploy, + // local Ctrl-C, OOM-kill, etc.) we want to: + // 1. Stop accepting new HTTP connections immediately, so a webhook + // that arrives during the kill window doesn't silently fail + // partway through (GitHub will retry it; better than us claiming + // we accepted it then dying). + // 2. Let already-dispatched agent sessions finish their + // session.create+session.prompt round-trip rather than die + // mid-flight and leave a half-baked session row on the host. + // + // Bun.serve.stop(true) closes the listening socket immediately, so + // step 1 is just that. For step 2 we await `waitForDrain()` — the + // counter is incremented inside dispatchOne and decremented in its + // finally block, so it covers both queued-on-semaphore and actively- + // running dispatches. A 25s ceiling guards against an agent that's + // hung on an external call; opencode itself will get its SIGTERM + // shortly after ours from the same orchestrator and tear things + // down regardless. + let stopping = false + const onShutdown = async (sig: NodeJS.Signals) => { + if (stopping) return + stopping = true + console.log( + `[github-webhooks] received ${sig}, closing listener (in-flight dispatches: ${inFlightDispatches})`, + ) + server.stop(true) + const drainTimeoutMs = 25_000 + try { + await Promise.race([ + waitForDrain(), + new Promise((_, reject) => + setTimeout( + () => reject(new Error("drain timeout")), + drainTimeoutMs, + ), + ), + ]) + console.log(`[github-webhooks] all dispatches drained`) + } catch { + console.warn( + `[github-webhooks] drain timeout after ${drainTimeoutMs}ms — ${inFlightDispatches} dispatch(es) still in flight`, + ) + } + } + process.on("SIGTERM", () => void onShutdown("SIGTERM")) + process.on("SIGINT", () => void onShutdown("SIGINT")) + // Plugin hooks. We don't currently need any event hooks — the entire // value of this plugin is the listener it opened above. Returning {} // is fine; the listener stays alive as long as the host process does. return {} } -// OpenCode's plugin loader looks for any exported function. We export -// our plugin as both the named export above (for tests / explicit -// import) and `default` for the auto-loader. +// `default` is purely ergonomic — OpenCode's plugin loader picks up any +// exported function from a file in ~/.config/opencode/plugins/, so the +// named export above is sufficient. Keeping `default` so the file also +// works for callers that prefer `import x from "./github-webhooks"`. export default GitHubWebhooksPlugin From 3ee264c57e1b93262f7c34a68db0ab1dab04c0db Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:22:09 +0000 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20bundle=20default=20webhooks.json=20?= =?UTF-8?q?(issue-assigned=20=E2=86=92=20github-issue-resolver)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship a working webhooks.json baked into the image so the github-webhooks plugin activates the moment GITHUB_WEBHOOK_SECRET is set — no further setup needed for the headline 'issue assigned → PR opened' flow. The bundled config has one trigger: issues.assigned → github-issue-resolver The prompt_template renders the repo, issue number/title/body/url, assignee, author, and labels into a context-rich prompt for the agent. Override mechanics: - Edit webhooks.json in this repo and rebuild — triggers stay version-controlled. - Set WEBHOOKS_CONFIG=/home/developer/dev/.opencode/webhooks.json (or any path on the persistent volume) to point at a runtime- editable config without rebuilding. The HMAC secret is intentionally NOT in the file — it stays as the GITHUB_WEBHOOK_SECRET env var so it isn't baked into image layers. Verified end-to-end with a realistic GitHub issues.assigned payload: plugin loads the baked-in file, listener binds, the trigger matches, session.create + session.prompt are called with the right agent ('github-issue-resolver'), and the rendered prompt correctly interpolates repo/issue/assignee/author from the payload. --- .env.example | 25 ++++++++++++++----------- Dockerfile | 11 +++++++++++ README.md | 35 +++++++++++++++++++++++++++-------- webhooks.json | 14 ++++++++++++++ 4 files changed, 66 insertions(+), 19 deletions(-) create mode 100644 webhooks.json diff --git a/.env.example b/.env.example index 1b3325a..b4b3baa 100644 --- a/.env.example +++ b/.env.example @@ -40,20 +40,23 @@ GH_TOKEN= # === Optional: github-webhooks plugin === # This image ships a plugin (~/.config/opencode/plugins/github-webhooks.ts) -# that turns inbound GitHub webhooks into OpenCode agent sessions. The -# plugin only opens its listener if a config file is present at the -# resolved path AND that config defines at least one trigger. -# -# Path to the JSON config file describing webhook triggers. Default -# resolves to ~/.config/opencode/webhooks.json — set this env var only -# if you want the file somewhere else (e.g. on the persistent volume). -# WEBHOOKS_CONFIG=/home/developer/dev/.opencode/webhooks.json +# that turns inbound GitHub webhooks into OpenCode agent sessions. A +# baseline config file is already baked in at +# ~/.config/opencode/webhooks.json — it wires `issues.assigned` to the +# bundled `github-issue-resolver` agent. Setting GITHUB_WEBHOOK_SECRET +# below activates the listener on port 5050 with that default trigger. -# HMAC secret matching what you configure in GitHub's webhook UI. Without -# this (and without the `secret` field in the JSON config) the listener -# rejects every delivery with 503. +# HMAC secret matching what you configure in GitHub's webhook UI. +# Required to receive webhooks — without it the listener rejects every +# delivery with 503. Set the same value here and in GitHub's webhook UI. GITHUB_WEBHOOK_SECRET= +# Override the bundled webhooks.json with one of your own. Default +# resolves to ~/.config/opencode/webhooks.json (the baked-in file). +# Point this at a path on the persistent ~/dev volume to customize +# triggers without rebuilding the image. +# WEBHOOKS_CONFIG=/home/developer/dev/.opencode/webhooks.json + # Port the plugin's webhook listener binds to. Defaults to 5050. Expose # this separately from the opencode web UI port (4096 / $PORT) on your # platform. diff --git a/Dockerfile b/Dockerfile index e99638f..05dc17a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -202,6 +202,17 @@ RUN cd /home/developer/.config/opencode \ && bun install --production \ && rm -rf ~/.bun/install/cache +# Default config for the github-webhooks plugin: one trigger that wires +# the `issues.assigned` event to the bundled `github-issue-resolver` +# agent. The plugin reads this on startup; without it, the listener +# stays off (no surprise port). Override per-deploy by setting +# WEBHOOKS_CONFIG to a path on your persistent volume (e.g. +# ~/dev/.opencode/webhooks.json) and putting your own file there. The +# HMAC secret is intentionally NOT in this file — set +# GITHUB_WEBHOOK_SECRET as an env var so it isn't baked into the image. +COPY --chown=developer:developer webhooks.json \ + /home/developer/.config/opencode/webhooks.json + # Tiny entrypoint that mkdir's ~/dev/.opencode at runtime so a single # Railway Volume mounted at ~/dev persists projects + OpenCode session/auth # data together (~/.local/share/opencode is symlinked into it). diff --git a/README.md b/README.md index a45f1ec..4d506ad 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Self-hosted [OpenCode](https://opencode.ai) web UI in a Docker image, ready to d - **OpenCode** built from source from the [`BYK/opencode`](https://github.com/BYK/opencode/tree/byk/cumulative) fork (`byk/cumulative` branch) — carries question-dock UX, plan-mode, and db perf fixes that aren't yet in upstream. Built fresh into the image; auto-update is effectively disabled because the fork has no release feed. - [Sentry CLI](https://cli.sentry.dev), GitHub CLI, **nvm + Node 22 LTS** (`pnpm` / `yarn` via corepack), **Bun**, plus `git`, `ripgrep`, `fd`, `fzf`, `jq`, `yq`, and `build-essential`. - No MCP servers preconfigured — add your own via a project-local `opencode.json` or by editing [`opencode-user-config.json`](./opencode-user-config.json) before building. -- **Bundled OpenCode plugin: [`github-webhooks`](./plugins/github-webhooks.ts)** — turns inbound GitHub webhook deliveries into OpenCode agent sessions running in the same `opencode` process. Listener stays off until you create a config file with at least one trigger (see [GitHub webhooks → agent sessions](#github-webhooks--agent-sessions) below). +- **Bundled OpenCode plugin: [`github-webhooks`](./plugins/github-webhooks.ts)** — turns inbound GitHub webhook deliveries into OpenCode agent sessions running in the same `opencode` process. Ships with [`webhooks.json`](./webhooks.json) configured with one default trigger (issue assigned → `github-issue-resolver`). Activates on container start once you set `GITHUB_WEBHOOK_SECRET`. See [GitHub webhooks → agent sessions](#github-webhooks--agent-sessions). - **Bundled agent: [`github-issue-resolver`](./agents/github-issue-resolver.md)** — autonomous "issue assigned → branch → plan → implement → PR" workflow, designed to be invoked by the webhook plugin or directly via `@github-issue-resolver`. - Non-root `developer` user. OpenCode starts in `~/dev`. Mount a single persistent volume at `~/dev` (= `/home/developer/dev`) to keep your projects **and** OpenCode session/auth data across redeploys — `~/.local/share/opencode` is symlinked into `~/dev/.opencode`. @@ -55,12 +55,31 @@ supervise, no loopback HTTP. It opens its own listener on port `5050` (configurable via `WEBHOOK_PORT`) and dispatches verified deliveries into agent sessions via the in-process SDK client. -The plugin stays dormant until you give it a config file. By default it -looks at `~/.config/opencode/webhooks.json`; override the path with -`WEBHOOKS_CONFIG` (handy if you want the file on the `~/dev` volume so -it survives image rebuilds). +### Default behavior -### Example config +The image ships with [`webhooks.json`](./webhooks.json) baked in at +`~/.config/opencode/webhooks.json`. It defines **one trigger**: when an +issue is assigned to someone, run the [`github-issue-resolver`](./agents/github-issue-resolver.md) agent against that +repo and issue. + +Once `GITHUB_WEBHOOK_SECRET` is set in your environment, the plugin +boots its listener on port 5050 automatically. No further setup needed. + +### Overriding the default config + +The bundled file is fine for the "issue assigned → resolve it" flow +out of the box. To customize: + +- **Edit before building** — change [`webhooks.json`](./webhooks.json) in + this repo and rebuild the image. Triggers stay version-controlled. +- **Override at runtime** — set `WEBHOOKS_CONFIG=/home/developer/dev/.opencode/webhooks.json` + (or any other path) and put your own file there. Handy for adding + per-deployment triggers without rebuilding. + +The HMAC secret (`secret` field) is intentionally **not** baked into the +file — set `GITHUB_WEBHOOK_SECRET` as an env var instead. + +### Config schema ```json { @@ -70,11 +89,11 @@ it survives image rebuilds). "retention": 1000, "triggers": [ { - "name": "issue-assigned-to-me", + "name": "issue-assigned", "event": "issues", "action": "assigned", "agent": "github-issue-resolver", - "prompt_template": "Resolve issue #{{ payload.issue.number }} ({{ payload.issue.title }}) in {{ payload.repository.full_name }}.\n\nIssue body:\n{{ payload.issue.body }}\n\nAssignee: {{ payload.assignee.login }}.\n\nFollow your standard workflow: clone, branch, plan, implement, push, open PR.", + "prompt_template": "Resolve issue #{{ payload.issue.number }} ({{ payload.issue.title }}) in {{ payload.repository.full_name }}.\n\n{{ payload.issue.body }}", "cwd": null } ] diff --git a/webhooks.json b/webhooks.json new file mode 100644 index 0000000..bed9fc9 --- /dev/null +++ b/webhooks.json @@ -0,0 +1,14 @@ +{ + "max_concurrent": 2, + "timeout_ms": 1800000, + "retention": 1000, + "triggers": [ + { + "name": "issue-assigned", + "event": "issues", + "action": "assigned", + "agent": "github-issue-resolver", + "prompt_template": "A GitHub issue has just been assigned to you. Resolve it end-to-end following your standard workflow.\n\nRepo: {{ payload.repository.full_name }}\nIssue: #{{ payload.issue.number }} — {{ payload.issue.title }}\nAssignee: {{ payload.assignee.login }}\nAuthor: {{ payload.issue.user.login }}\nURL: {{ payload.issue.html_url }}\n\nIssue body:\n---\n{{ payload.issue.body }}\n---\n\nLabels: {{ payload.issue.labels }}\n\nClone (or update) the repo under ~/dev//, branch off the default branch, plan first, implement the smallest change that resolves the issue, push, and open a PR with `Closes #{{ payload.issue.number }}` in the body. If the change is larger than ~5 files or touches public APIs, post a comment on the issue and emit BLOCKED instead of opening a PR." + } + ] +} From 9b0769bd76965a67be492f0cbcac61e13ac24809 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Thu, 30 Apr 2026 19:32:24 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix(plugin):=20hardening=20pass=20=E2=80=94?= =?UTF-8?q?=20unref=20abort=20timer,=20body=20size=20cap,=20once=20handler?= =?UTF-8?q?s,=20lockfile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness ----------- - P1: timer.unref() on the per-dispatch abort setTimeout. The 30-min timer was keeping Bun's event loop alive past intentional shutdown, which made the process appear hung in logs even after graceful drain completed. unref() lets the loop exit naturally once the dispatch finishes (or is canceled by the drain logic). - A2: 25 MB body size cap on POST /webhooks/github, enforced both via Content-Length and on actual rawBody.length (defense in depth — Bun normalizes Content-Length to actual byte length, but a future runtime change could break that assumption). GitHub caps webhook payloads at 25 MB, so this only refuses pathological/attacker traffic. Verified: 26 MB body returns 413 before HMAC verification. - P3: process.once for SIGTERM/SIGINT instead of process.on. Prevents listener accumulation if the plugin is ever re-initialized in the same process. Bonus: a second SIGTERM after shutdown is initiated hits Node's default handler (force exit), which is what an operator pressing Ctrl-C twice usually wants. Reproducibility --------------- - O1: committed opencode-config-bun.lock alongside the package.json, and switched the Dockerfile to bun install --frozen-lockfile so builds resolve to the same @opencode-ai/plugin transitive tree on every rebuild. Without this, a caret-ranged dep could silently bump on the next image build. Documentation ------------- - A7: agent prompt now includes a defensive 'reset before re-using a cloned repo' block (git reset --hard origin/ + git clean -fd) so a previous run's leftover branch/dirty tree doesn't wedge the new run with a 'checkout failed' error. - R1: README example trigger is now labelled 'minimum-viable trigger' with an explicit pointer to webhooks.json as the working reference. Was confusing because the README example was simpler than the bundled file. - R4/R5: clarified the health check is on the plugin's port (not OpenCode's 4096), and that the env var + bundled webhooks.json TOGETHER are what activates the listener (not the env var alone). - S1/S2/S3/S5: stale comments in plugin module-doc and types updated to match current behavior. Repeated globalThis cast extracted to a single 'guard' const. --- Dockerfile | 4 +- README.md | 20 +++++---- agents/github-issue-resolver.md | 18 ++++++-- opencode-config-bun.lock | 73 +++++++++++++++++++++++++++++++++ plugins/github-webhooks.ts | 59 +++++++++++++++++++++----- 5 files changed, 151 insertions(+), 23 deletions(-) create mode 100644 opencode-config-bun.lock diff --git a/Dockerfile b/Dockerfile index 05dc17a..72a5b42 100644 --- a/Dockerfile +++ b/Dockerfile @@ -198,8 +198,10 @@ COPY --chown=developer:developer plugins \ /home/developer/.config/opencode/plugins COPY --chown=developer:developer opencode-config-package.json \ /home/developer/.config/opencode/package.json +COPY --chown=developer:developer opencode-config-bun.lock \ + /home/developer/.config/opencode/bun.lock RUN cd /home/developer/.config/opencode \ - && bun install --production \ + && bun install --frozen-lockfile --production \ && rm -rf ~/.bun/install/cache # Default config for the github-webhooks plugin: one trigger that wires diff --git a/README.md b/README.md index 4d506ad..204871b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Self-hosted [OpenCode](https://opencode.ai) web UI in a Docker image, ready to d - **OpenCode** built from source from the [`BYK/opencode`](https://github.com/BYK/opencode/tree/byk/cumulative) fork (`byk/cumulative` branch) — carries question-dock UX, plan-mode, and db perf fixes that aren't yet in upstream. Built fresh into the image; auto-update is effectively disabled because the fork has no release feed. - [Sentry CLI](https://cli.sentry.dev), GitHub CLI, **nvm + Node 22 LTS** (`pnpm` / `yarn` via corepack), **Bun**, plus `git`, `ripgrep`, `fd`, `fzf`, `jq`, `yq`, and `build-essential`. - No MCP servers preconfigured — add your own via a project-local `opencode.json` or by editing [`opencode-user-config.json`](./opencode-user-config.json) before building. -- **Bundled OpenCode plugin: [`github-webhooks`](./plugins/github-webhooks.ts)** — turns inbound GitHub webhook deliveries into OpenCode agent sessions running in the same `opencode` process. Ships with [`webhooks.json`](./webhooks.json) configured with one default trigger (issue assigned → `github-issue-resolver`). Activates on container start once you set `GITHUB_WEBHOOK_SECRET`. See [GitHub webhooks → agent sessions](#github-webhooks--agent-sessions). +- **Bundled OpenCode plugin: [`github-webhooks`](./plugins/github-webhooks.ts)** — turns inbound GitHub webhook deliveries into OpenCode agent sessions running in the same `opencode` process. Ships with [`webhooks.json`](./webhooks.json) baked in (one default trigger: issue assigned → `github-issue-resolver`). Activates on container start once you set `GITHUB_WEBHOOK_SECRET` — the env var plus the bundled file are all you need. See [GitHub webhooks → agent sessions](#github-webhooks--agent-sessions). - **Bundled agent: [`github-issue-resolver`](./agents/github-issue-resolver.md)** — autonomous "issue assigned → branch → plan → implement → PR" workflow, designed to be invoked by the webhook plugin or directly via `@github-issue-resolver`. - Non-root `developer` user. OpenCode starts in `~/dev`. Mount a single persistent volume at `~/dev` (= `/home/developer/dev`) to keep your projects **and** OpenCode session/auth data across redeploys — `~/.local/share/opencode` is symlinked into `~/dev/.opencode`. @@ -81,25 +81,27 @@ file — set `GITHUB_WEBHOOK_SECRET` as an env var instead. ### Config schema +The minimum-viable trigger: + ```json { - "port": 5050, - "max_concurrent": 2, - "timeout_ms": 1800000, - "retention": 1000, "triggers": [ { "name": "issue-assigned", "event": "issues", "action": "assigned", "agent": "github-issue-resolver", - "prompt_template": "Resolve issue #{{ payload.issue.number }} ({{ payload.issue.title }}) in {{ payload.repository.full_name }}.\n\n{{ payload.issue.body }}", - "cwd": null + "prompt_template": "Resolve issue #{{ payload.issue.number }} in {{ payload.repository.full_name }}." } ] } ``` +The bundled [`webhooks.json`](./webhooks.json) is richer — its +`prompt_template` interpolates the issue title, body, assignee, author, +URL, and labels into a context-heavy prompt for the agent. Use that as +the working reference when writing your own trigger. + Field reference: | Field | Required | What it does | @@ -138,7 +140,9 @@ afterward — view it in OpenCode's UI like any other session. ### Health check -`GET http://:5050/healthz` returns `{ "ok": true, "plugin": "github-webhooks" }` once the listener is up. No auth required. +`GET http://:5050/healthz` (the plugin's port, not OpenCode's +4096) returns `{ "ok": true, "plugin": "github-webhooks" }` once the +listener is up. No auth required. ## Local test diff --git a/agents/github-issue-resolver.md b/agents/github-issue-resolver.md index 45e8cb4..db5230c 100644 --- a/agents/github-issue-resolver.md +++ b/agents/github-issue-resolver.md @@ -30,10 +30,20 @@ intervention, while staying conservative about scope. ```sh gh repo clone / ~/dev// -- --depth=50 ``` - If the directory already exists, `cd` into it and run `git fetch --all`, - then check out the repo's default branch (resolve it via - `gh repo view --json defaultBranchRef --jq .defaultBranchRef.name`) - and `git pull`. + If the directory already exists, an earlier issue-resolution session + may have left it on a feature branch with uncommitted changes. Reset + defensively before doing anything else: + ```sh + cd ~/dev// + DEFAULT_BRANCH=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name) + git fetch --all --prune + git reset --hard "origin/$DEFAULT_BRANCH" # discard local changes + git clean -fd # remove untracked files + git checkout "$DEFAULT_BRANCH" + ``` + This guarantees you start from a clean tree on the default branch. + If the repo had uncommitted work that mattered, that's the previous + run's bug — not yours to recover. 2. **Create a feature branch** named `issue--`: ```sh diff --git a/opencode-config-bun.lock b/opencode-config-bun.lock new file mode 100644 index 0000000..2de571e --- /dev/null +++ b/opencode-config-bun.lock @@ -0,0 +1,73 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "opencode-config", + "dependencies": { + "@opencode-ai/plugin": "^1.14.30", + }, + }, + }, + "packages": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], + + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.14.30", "", { "dependencies": { "@opencode-ai/sdk": "1.14.30", "effect": "4.0.0-beta.57", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.1.105", "@opentui/solid": ">=0.1.105" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-O1y6qR349R5XJtB76Vx4TO/uvLkqNvdsgxtj2ZpWoygb3rtrkuVMiBsrcL2WfuyyaLJnPmbnkeigcdm42r09Hg=="], + + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.30", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-OgPEDvALekHZIjByo/okJ699aLPn+XtsVxgZxUqE8TlzAG7TtskMGFl0fro8O0T2p+nkOT/LstnKGbECvc0+YA=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "effect": ["effect@4.0.0-beta.57", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-rg32VgXnLKaPRs9tbRDaZ5jxmzNY7ojXt85gSHGUTwdlbWH5Ik+OCUY2q14TXliygPGoHwCAvNWS4bQJOqf00g=="], + + "fast-check": ["fast-check@4.7.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ=="], + + "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], + + "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + + "msgpackr": ["msgpackr@1.11.10", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], + + "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], + + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="], + + "uuid": ["uuid@13.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-9ezox2roIft6ExBVTVqibSd5dc5/47Sw/uY6b4SjQUT2TzQ0tltNquWA46y4xPQmdZYqvnio22SgWd41M86+jw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + } +} diff --git a/plugins/github-webhooks.ts b/plugins/github-webhooks.ts index 39fe108..d90f655 100644 --- a/plugins/github-webhooks.ts +++ b/plugins/github-webhooks.ts @@ -10,9 +10,10 @@ // - The SDK client we get from ctx.client targets THIS server, no // loopback HTTP and no cold-boot race. // - Trigger config is a JSON file (default ~/.config/opencode/webhooks.json, -// overridable with WEBHOOKS_CONFIG), kept out of opencode.json -// because that file's schema doesn't admit our experimental.webhook -// extension. +// overridable with WEBHOOKS_CONFIG). This is the documented user +// surface — version-controlled in the repo, edited at deploy time, +// or pointed at a path on the persistent volume to mutate without +// rebuilding. // // Trade-off: an unhandled rejection here can crash the OpenCode server. // We catch aggressively at the dispatch boundary and rely on the @@ -50,10 +51,12 @@ type WebhookConfig = { // Max concurrent agent sessions. Default 2. max_concurrent?: number // Default working directory for sessions when a trigger doesn't - // specify one. Falls back to ctx.directory (project root). + // specify one. Falls back to ctx.directory (whatever opencode hands + // us at plugin-load time; usually the project root). default_cwd?: string // Path to the deduplication SQLite file. Default - // /.opencode/github-webhooks.sqlite. + // ~/dev/.opencode/github-webhooks.sqlite — co-located with opencode's + // own session data on the persistent Railway volume. db_path?: string // Hard cap on persisted webhook deliveries. Default 1000. retention?: number @@ -202,12 +205,14 @@ function makeSemaphore(limit: number) { export const GitHubWebhooksPlugin: Plugin = async (ctx) => { // Process-level guard. A bug in our dispatch path must not take down - // the host opencode server. We log and swallow. - if (!(globalThis as { __ghWebhookGuard?: boolean }).__ghWebhookGuard) { + // the host opencode server. We log and swallow. Gated so we only ever + // install the listener once even if the plugin is re-initialized. + const guard = globalThis as { __ghWebhookGuard?: boolean } + if (!guard.__ghWebhookGuard) { process.on("unhandledRejection", (err) => { console.error("[github-webhooks] unhandledRejection:", err) }) - ;(globalThis as { __ghWebhookGuard?: boolean }).__ghWebhookGuard = true + guard.__ghWebhookGuard = true } const cfg = await readWebhookConfig() @@ -306,6 +311,12 @@ export const GitHubWebhooksPlugin: Plugin = async (ctx) => { await sem.acquire() const abort = new AbortController() const timer = setTimeout(() => abort.abort(), timeoutMs) + // Don't keep the event loop alive just for the abort timer — on + // SIGTERM the dispatch should either complete naturally or be + // canceled by the drain logic, NOT block exit because a 30-min + // timer hasn't fired yet. Bun supports unref(); guard so the call + // is a no-op on runtimes that don't. + timer.unref?.() try { const session = await ctx.client.session.create({ body: { title: `[webhook/${t.name}] ${t.event}` }, @@ -383,7 +394,30 @@ export const GitHubWebhooksPlugin: Plugin = async (ctx) => { ) } + // Body size cap. GitHub caps webhook payloads at 25 MB; anything + // larger is either a misbehaving client or an attacker trying to + // OOM us via `await req.text()`. Refuse based on the + // Content-Length header before we ever read the body. (The same + // header is part of what HMAC will protect anyway, so a forged + // size would also fail signature verification.) + const MAX_BODY_BYTES = 25 * 1024 * 1024 + const declaredLength = Number(req.headers.get("content-length") ?? "0") + if (declaredLength > MAX_BODY_BYTES) { + return Response.json( + { error: "payload too large" }, + { status: 413 }, + ) + } + const rawBody = await req.text() + // Defense in depth: if a client sent without Content-Length or + // lied about it, enforce the same cap on the actual bytes. + if (rawBody.length > MAX_BODY_BYTES) { + return Response.json( + { error: "payload too large" }, + { status: 413 }, + ) + } const signature = req.headers.get("x-hub-signature-256") if (!verifyGithubSignature(rawBody, signature, secret)) { return Response.json({ error: "invalid signature" }, { status: 401 }) @@ -494,8 +528,13 @@ export const GitHubWebhooksPlugin: Plugin = async (ctx) => { ) } } - process.on("SIGTERM", () => void onShutdown("SIGTERM")) - process.on("SIGINT", () => void onShutdown("SIGINT")) + // process.once (not on) so we don't accumulate listeners if the + // plugin is ever re-initialized in the same process (which OpenCode + // doesn't currently do, but the protection is cheap). A second + // SIGTERM after the first will hit Node's default handler and force + // exit — which is what an operator pressing ^C twice usually wants. + process.once("SIGTERM", () => void onShutdown("SIGTERM")) + process.once("SIGINT", () => void onShutdown("SIGINT")) // Plugin hooks. We don't currently need any event hooks — the entire // value of this plugin is the listener it opened above. Returning {}