Skip to content

feat: GitHub webhooks → OpenCode agent sessions (plugin + bundled config + agent) - #5

Merged
MathurAditya724 merged 4 commits into
mainfrom
add-hono-sidecar
Apr 30, 2026
Merged

feat: GitHub webhooks → OpenCode agent sessions (plugin + bundled config + agent)#5
MathurAditya724 merged 4 commits into
mainfrom
add-hono-sidecar

Conversation

@MathurAditya724

@MathurAditya724 MathurAditya724 commented Apr 30, 2026

Copy link
Copy Markdown
Member

End-to-end automation for "GitHub issue gets assigned → OpenCode resolves it → opens PR", implemented as an OpenCode plugin that loads inside the long-lived opencode server process.

Once you set GITHUB_WEBHOOK_SECRET on a deploy, this image:

  1. Opens a webhook listener on port 5050
  2. Verifies inbound issues.assigned deliveries against your HMAC secret
  3. Spawns an opencode agent session running github-issue-resolver against the right repo
  4. The agent clones the repo, branches, plans, implements, pushes, and opens a PR — all without further input

Note: branch is add-hono-sidecar because this PR originally implemented the same goal as a Bun+Hono sidecar process. Once we confirmed plugins load into the host server (with an in-process SDK client), the sidecar approach was dropped in favor of the simpler plugin design. Branch name kept to preserve the PR URL.

What's bundled

File Role
plugins/github-webhooks.ts (~510 lines) The plugin. Opens a Bun.serve listener on WEBHOOK_PORT (default 5050). Verifies X-Hub-Signature-256, dedups on X-GitHub-Delivery, dispatches matching triggers via ctx.client.session.create + ctx.client.session.prompt. Concurrency cap (default 2), per-session timeout (default 30 min), retention cap (default 1000 dedup rows).
agents/github-issue-resolver.md The bundled primary agent. Walks an issue from "assigned" to "PR opened" using the bundled gh + git CLIs. Authenticated via GH_TOKEN. Includes defensive working-tree reset for re-using cloned repos across runs.
webhooks.json Default trigger config baked into the image. One trigger: issues.assignedgithub-issue-resolver. The prompt template interpolates repo, issue number/title/body/url, assignee, author, and labels.
opencode-config-package.json + opencode-config-bun.lock Copied to ~/.config/opencode/{package.json,bun.lock} so the plugin's import type { Plugin } resolves at startup. Lockfile committed for reproducible builds.
Dockerfile (+44 LOC) COPYs all of the above into ~/.config/opencode/ at build time. Runs bun install --frozen-lockfile --production once. Exposes port 5050.
.env.example (+24 LOC) Documents GITHUB_WEBHOOK_SECRET (the activation switch), WEBHOOKS_CONFIG, WEBHOOK_PORT.
README.md (+103 LOC) New "GitHub webhooks → agent sessions" section: default behavior, override mechanics (rebuild vs runtime), config schema, field reference table, GitHub webhook UI setup, Railway routing note, health check.

Plugin uses zero npm runtime deps — only Bun built-ins (Bun.serve, bun:sqlite, node:crypto). @opencode-ai/plugin is import type-only.

Architecture

The plugin runs inside opencode's process and uses ctx.client (the in-process SDK client provided by the plugin context) to drive sessions directly. No loopback HTTP, no cold-boot race against opencode's HTTP server, no second process to supervise.

Trade-off: an unhandled rejection in the plugin can crash opencode-web. Mitigated with a top-level unhandledRejection guard, aggressive try/catch at the dispatch boundary, body-size caps, and graceful SIGTERM handling that lets in-flight dispatches drain (with a 25s ceiling).

Trigger config

Stored as JSON. Default path ~/.config/opencode/webhooks.json (the baked-in file); overridable with the WEBHOOKS_CONFIG env var pointing to a path on the persistent ~/dev volume. Not stored in opencode.json because that file's published schema declares experimental.additionalProperties: false.

Multiple triggers can match the same delivery — all matching enabled triggers fire concurrently, capped by max_concurrent. The HMAC secret is intentionally not in the JSON file — kept as GITHUB_WEBHOOK_SECRET env var only.

How it works end-to-end

  1. GitHub fires issues.assignedPOST /webhooks/github on port 5050.
  2. Plugin enforces 25 MB body size cap (matches GitHub's webhook limit).
  3. Plugin verifies HMAC against GITHUB_WEBHOOK_SECRET using timingSafeEqual.
  4. Plugin dedups on X-GitHub-Delivery via SQLite. Redeliveries are ack'd as duplicate: true and don't re-fire.
  5. Plugin matches (event, action) against the trigger list. Every enabled trigger that matches fires.
  6. Each match: render the prompt template against the payload, then invoke the agent via client.session.create + client.session.prompt.
  7. The bundled agent runs: git fetch --all --prunegit reset --hard origin/<default> (defensive) → branch → plan → implement → push → gh pr create.

The host opencode server is the system of record for the resulting session — view it in the web UI like any other.

Railway deploy

GITHUB_WEBHOOK_SECRET=<same value as GitHub webhook UI>
GH_TOKEN=<PAT with repo, read:org, workflow scopes>
ANTHROPIC_API_KEY=<or any other LLM provider key>

Railway only generates one HTTP domain per service, so port 5050 needs separate routing — second Railway service pointing at the same image, a TCP proxy, or Cloudflare. The opencode web UI on 4096/$PORT is independent.

Security model

  • HMAC verification reads raw body before any JSON parse, prefix-checks sha256=, length-equality before timingSafeEqual.
  • 25 MB body cap enforced via Content-Length header AND actual rawBody.length (defense in depth).
  • Required headers (X-GitHub-Event, X-GitHub-Delivery) enforced before HMAC, so non-GitHub sources can't synthesize delivery IDs to flood the dedup table or match event: "*" rules.
  • Idempotent on redelivery — dispatch only fires when SQLite INSERT...ON CONFLICT DO NOTHING actually inserted.
  • Concurrency cap prevents trigger fan-out from blowing through token budgets.
  • Per-session timeout (default 30 min) aborts runaway agents; the underlying setTimeout is unref()'d so it doesn't hold the event loop past intentional shutdown.
  • Graceful SIGTERM/SIGINT (registered with process.once to avoid listener accumulation): closes the listening socket immediately, drains in-flight dispatches with a 25s ceiling.

Verified

Standalone harness with a mock SDK client. Tests exercise:

  • Missing X-GitHub-Event / X-GitHub-Delivery → 400 ✓
  • Bad HMAC → 401 ✓
  • 26 MB body → 413 before HMAC ✓
  • Valid first delivery → 200, dispatches every matching trigger ✓
  • Redelivery of same delivery_id → 200, duplicate:true, dispatched:[], no extra SDK calls ✓
  • 6-trigger config (specific, event-only, catch-all, mismatched, disabled): exactly the 3 matching triggers fire ✓
  • SIGTERM → drain log fires, Bun.serve socket closes, post-SIGTERM fetch gets ConnectionRefused, process exits cleanly despite pending 30-min abort timers ✓
  • Bundled webhooks.json + agent github-issue-resolver: realistic issues.assigned payload renders correctly into the prompt template ✓
  • tsc --noEmit clean.

Commits

fd07425 Initial plugin + agent + bundled config
cff9a3d Review fixes: matching semantics (drop dead priority/sort), null normalization, dbPath default to ~/dev/.opencode/, log message clarity, graceful shutdown
3ee264c Bundle default webhooks.json so the feature works the moment GITHUB_WEBHOOK_SECRET is set
9b0769b Hardening pass: timer.unref() for clean exit, 25 MB body cap, process.once handlers, frozen lockfile, defensive working-tree reset in agent, README example clarification, comment cleanup

Out of scope / follow-ups

  • No HTTP API for managing triggers at runtime. Triggers live in a JSON file; editing them requires either a rebuild or repointing WEBHOOKS_CONFIG at a writable path on the volume + restart.
  • No streaming session output back to a webhook caller. The plugin awaits session.prompt to completion; for very long-running agents you'd want client.event.subscribe and an SSE relay.
  • Process isolation is weaker than a separate sidecar would provide — a plugin bug crashing opencode-web takes the web UI down. Mitigated but not eliminated.
  • Branch name is add-hono-sidecar (legacy from prior design). Rename out-of-scope; PR URL stays.

Base automatically changed from remove-sentry-and-github-mcp to main April 30, 2026 17:10
@MathurAditya724 MathurAditya724 changed the title feat: add Hono sidecar for GitHub webhooks + cron management feat: Hono sidecar with cron + GitHub webhooks → OpenCode agent dispatch Apr 30, 2026
@MathurAditya724 MathurAditya724 changed the title feat: Hono sidecar with cron + GitHub webhooks → OpenCode agent dispatch feat: Hono sidecar — GitHub webhooks → OpenCode agent dispatch Apr 30, 2026
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.
@MathurAditya724 MathurAditya724 changed the title feat: Hono sidecar — GitHub webhooks → OpenCode agent dispatch feat: GitHub webhooks → OpenCode agent sessions, as a plugin Apr 30, 2026
…Path, log clarity

- 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).
…olver)

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.
…handlers, lockfile

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/<default> + 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.
@MathurAditya724
MathurAditya724 merged commit 0e782bb into main Apr 30, 2026
1 check passed
@MathurAditya724
MathurAditya724 deleted the add-hono-sidecar branch April 30, 2026 19:33
@MathurAditya724 MathurAditya724 changed the title feat: GitHub webhooks → OpenCode agent sessions, as a plugin feat: GitHub webhooks → OpenCode agent sessions (plugin + bundled config + agent) Apr 30, 2026
MathurAditya724 added a commit that referenced this pull request May 1, 2026
- opencode-webhooks README: replace stale `x-email-from` header reference with JSON `from` field (finding #1).
- http.ts: rewrite MAX_BODY_BYTES comment (no longer about RFC822); add MAX_EMAIL_BODY_BYTES = 64 KB and an optional maxBytes arg to readBodyBytes; email handler now uses the tighter cap (finding #2).
- cloudflare-email-worker README: note that a malformed regex literal in ALLOWED_SENDERS will throw at module init and the worker won't start (finding #3).
- email handler: parseEmailEvent's `str` now throws on non-string for required fields (from/to/subject/message_id) so the 400 detail names the offending field instead of misleading 'missing' messages (finding #4).
- Test gap for email identity fallback chain tracked in #14; no tests added in this commit (finding #5).
MathurAditya724 added a commit that referenced this pull request May 1, 2026
… SIDECAR_URL → WEBHOOK_URL (#13)

* refactor(email-worker): wrangler.json, inline ALLOWED_SENDERS, rename SIDECAR_URL → WEBHOOK_URL

- Replace wrangler.toml with wrangler.json (with $schema reference for
  editor validation). TOML and JSON are both first-class wrangler
  config formats; JSON keeps the worker package consistent with the
  rest of the repo's config files (package.json, tsconfig.json,
  webhooks.json).
- Move ALLOWED_SENDERS out of wrangler vars and into a top-level
  TypeScript const at the head of src/index.ts. The allowlist is
  PR-reviewed code now (typed as readonly string[]), compiled once at
  module load (zero per-request parse overhead), and a malformed regex
  fails the deploy instead of silently dropping at runtime. The Env
  shape no longer needs an ALLOWED_SENDERS field.
- Rename SIDECAR_URL → WEBHOOK_URL in wrangler.json vars, the Env
  interface, and the fetch call. 'Sidecar' was misleading anyway —
  the plugin is in-process with opencode, not a sidecar process.
  WEBHOOK_URL clearly names what it points at: the plugin's
  /webhooks/email endpoint.

* feat(email): worker becomes dumb pipe — unconditional forward + JSON event

The Cloudflare Email Worker now does two things per inbound email:

  1. message.forward(env.FORWARD_TO) unconditionally (if set), so every
     email reaches the operator's real inbox preserving DKIM. Wrapped
     in try/catch so a misconfigured FORWARD_TO doesn't block webhook
     dispatch — failure is logged loudly and we continue.
  2. If the From address is in ALLOWED_SENDERS, build a small JSON
     event from the headers we route on (from, to, subject,
     message_id, in_reply_to, references, list_id, x_github_reason,
     x_github_sender), HMAC-sign it, and POST as application/json to
     WEBHOOK_URL.

The worker no longer parses RFC822 in the plugin path — it just hands
Cloudflare's already-parsed headers to the plugin verbatim. The body
is never sent at all (it never was used; canonical state comes from
the GitHub API).

Plugin side:
- handlers/email.ts: reads req.json() instead of parsing RFC822;
  validates the event shape; everything downstream (identity, synth,
  dispatch) is unchanged.
- email/identity.ts: accepts the EmailEvent JSON shape instead of an
  EmailHeaders object. Same regex matchers; same in-reply-to /
  references fallback chain.
- email/synthesize.ts: reads metadata directly from the event object.
- email/parse.ts: deleted (no more RFC822 parsing).

Other changes:
- wrangler.json: added FORWARD_TO var (optional) and
  observability.logs.enabled = true so 'wrangler tail' and the
  Cloudflare dashboard show structured logs.
- READMEs: updated wire format, architecture diagrams, failure-mode
  table on the worker side.

Verified: bun run typecheck passes for both packages; wrangler deploy
--dry-run accepts the new config with both vars bound.

* fix: review findings on PR #13 (dumb-pipe + wrangler.json)

- opencode-webhooks README: replace stale `x-email-from` header reference with JSON `from` field (finding #1).
- http.ts: rewrite MAX_BODY_BYTES comment (no longer about RFC822); add MAX_EMAIL_BODY_BYTES = 64 KB and an optional maxBytes arg to readBodyBytes; email handler now uses the tighter cap (finding #2).
- cloudflare-email-worker README: note that a malformed regex literal in ALLOWED_SENDERS will throw at module init and the worker won't start (finding #3).
- email handler: parseEmailEvent's `str` now throws on non-string for required fields (from/to/subject/message_id) so the 400 detail names the offending field instead of misleading 'missing' messages (finding #4).
- Test gap for email identity fallback chain tracked in #14; no tests added in this commit (finding #5).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant